From 85d8bcd75af3fc78a9150032cb1342693a8a9d67 Mon Sep 17 00:00:00 2001 From: Ville Ojamo <14869000+bluikko@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:53:06 +0700 Subject: [PATCH 001/596] doc/radosgw: change all intra-docs links to use ref (5 of 6) Part 5 of 6 to make backporting easier. Depends on part 1. Use the the ref role for all remaining links in doc/radosgw/ with the exception of config-ref.rst which will depend on changes to rgw.yaml.in. The "external link definitions" syntax being removed is intended for linking to external websites and not for intra-docs links. Validity of ref links will be checked during the docs build process. Add labels for links targets if necessary. Remove unused external link definitions in the modified files. Use confval instead of literal text for 2 configuration keys in vault.rst. Signed-off-by: Ville Ojamo --- doc/radosgw/s3-notification-compatibility.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/radosgw/s3-notification-compatibility.rst b/doc/radosgw/s3-notification-compatibility.rst index efa746ba4d5..a4fc769bbea 100644 --- a/doc/radosgw/s3-notification-compatibility.rst +++ b/doc/radosgw/s3-notification-compatibility.rst @@ -4,7 +4,7 @@ S3 Bucket Notifications Compatibility ===================================== -Ceph's `Bucket Notifications`_ API follows `AWS S3 Bucket Notifications API`_. +Ceph's :ref:`radosgw-notifications` API follows `AWS S3 Bucket Notifications API`_. However, some differences exist, as listed below. @@ -193,5 +193,3 @@ We also have the following extensions to topic configuration: .. _AWS Simple Notification Service API: https://docs.aws.amazon.com/sns/latest/api/API_Operations.html .. _AWS S3 Bucket Notifications API: https://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html .. _Event Message Structure: https://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html -.. _`Bucket Notifications`: ../notifications -.. _`boto3 SDK filter extensions`: https://github.com/ceph/ceph/tree/main/examples/rgw/boto3 From 91c8003f475652357036c85a6040461aeff0539f Mon Sep 17 00:00:00 2001 From: Indira Sawant Date: Wed, 11 Mar 2026 15:55:42 -0500 Subject: [PATCH 002/596] mon/MgrMonitor: include standby manager details in ceph mgr stat Extend `ceph mgr stat` to include basic information about standby manager daemons in a new `standbys` field. This improves visibility into standby managers without requiring `ceph mgr dump`. Fixes: https://tracker.ceph.com/issues/75464 Signed-off-by: Indira Sawant --- src/mon/MgrMonitor.cc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mon/MgrMonitor.cc b/src/mon/MgrMonitor.cc index e387212ac09..1d8ac519c02 100644 --- a/src/mon/MgrMonitor.cc +++ b/src/mon/MgrMonitor.cc @@ -993,6 +993,14 @@ bool MgrMonitor::preprocess_command(MonOpRequestRef op) f->dump_bool("available", map.get_available()); f->dump_string("active_name", map.get_active_name()); f->dump_unsigned("num_standby", map.get_num_standby()); + f->open_array_section("standbys"); + for (const auto& [gid, s] : map.standbys) { + f->open_object_section("standby_mgr"); + f->dump_unsigned("gid", s.gid); + f->dump_string("name", s.name); + f->close_section(); + } + f->close_section(); f->close_section(); f->flush(rdata); } else if (prefix == "mgr dump") { From 77b3dcb38c19ebed061b1b9bb273db5a5dbc4963 Mon Sep 17 00:00:00 2001 From: Ville Ojamo <14869000+bluikko@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:49:34 +0700 Subject: [PATCH 003/596] doc/radosgw: change all intra-docs links to use ref (3 of 6) Part 3 of 6 to make backporting easier. Depends on part 1. Use the the ref role for all remaining links in doc/radosgw/ with the exception of config-ref.rst which will depend on changes to rgw.yaml.in. The "external link definitions" syntax being removed is intended for linking to external websites and not for intra-docs links. Validity of ref links will be checked during the docs build process. Add labels for links targets if necessary. Remove unused external link definitions in the modified files. Use confval instead of literal text for 2 configuration keys in vault.rst. Signed-off-by: Ville Ojamo --- doc/radosgw/adminops.rst | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/doc/radosgw/adminops.rst b/doc/radosgw/adminops.rst index 674b7b4a728..47ff5a6d6aa 100644 --- a/doc/radosgw/adminops.rst +++ b/doc/radosgw/adminops.rst @@ -2708,7 +2708,7 @@ Quotas ====== The Admin Operations API enables you to set quotas on users and on buckets owned -by users, and on accounts and on buckets owned by accounts. See `Quota Management`_ +by users, and on accounts and on buckets owned by accounts. See :ref:`radosgw-quota-management` for additional details. Quotas include the maximum number of objects in a bucket and the maximum storage size in megabytes. @@ -2717,7 +2717,7 @@ modify or disable a quota, the user must have ``users=write`` capability. To view quotas for accounts, the user must have a ``accounts=read`` capability. To set, modify or disable a quota, the user must have ``accounts=write`` capability. -See the `Admin Guide`_ for details. +See the :ref:`radosgw-admin-guide` for details. Valid parameters for quotas include: @@ -2813,13 +2813,13 @@ permission. :: Rate Limit ========== -The Admin Operations API enables you to set and get ratelimit configurations on users and on buckets and global rate limit configurations. See `Rate Limit Management`_ for additional details. +The Admin Operations API enables you to set and get ratelimit configurations on users and on buckets and global rate limit configurations. See :ref:`radosgw-rate-limit-management` for additional details. Rate Limit includes the maximum number of operations and/or bytes per accumulation interval, separated by read and/or write (Additionally list and get operations), to a bucket and/or by a user and the maximum storage size in megabytes. To view rate limit, the user must have a ``ratelimit=read`` capability. To set, modify or disable a ratelimit, the user must have ``ratelimit=write`` capability. -See the `Admin Guide`_ for details. +See the :ref:`radosgw-admin-guide` for details. Valid parameters for quotas include: @@ -2991,12 +2991,7 @@ Binding libraries -.. _Admin Guide: ../admin -.. _Quota Management: ../admin#quota-management -.. _IrekFasikhov/go-rgwadmin: https://github.com/IrekFasikhov/go-rgwadmin -.. _QuentinPerez/go-radosgw: https://github.com/QuentinPerez/go-radosgw .. _ceph/go-ceph: https://github.com/ceph/go-ceph/ -.. _Rate Limit Management: ../admin#rate-limit-management .. _IrekFasikhov/go-rgwadmin: https://github.com/IrekFasikhov/go-rgwadmin .. _QuentinPerez/go-radosgw: https://github.com/QuentinPerez/go-radosgw .. _twonote/radosgw-admin4j: https://github.com/twonote/radosgw-admin4j From 0709f29a27d266866e586f0dfe11e6164b3dbb9f Mon Sep 17 00:00:00 2001 From: Vivek Maurya Date: Sun, 8 Feb 2026 10:05:33 -0600 Subject: [PATCH 004/596] osd: Rephrase unclear OSD_FLAGS warning message Setting maintenance mode show below warning,which appears incorrect or ambiguous. [WRN] OSD_FLAGS: 1 OSDs or CRUSH {nodes, device-classes}have {NOUP,NODOWN,NOIN,NOOUT} flags set Change warning as shown bellow. [WRN] OSD_FLAGS: 1 OSDs or CRUSH nodes/device-classes have one or more of these flags set: NOUP, NODOWN, NOIN, NOOUT Fixes: https://tracker.ceph.com/issues/71716 Signed-off-by: Vivek Maurya --- src/osd/OSDMap.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/osd/OSDMap.cc b/src/osd/OSDMap.cc index 5583369c008..41969c99e24 100644 --- a/src/osd/OSDMap.cc +++ b/src/osd/OSDMap.cc @@ -7770,7 +7770,7 @@ void OSDMap::check_health(CephContext *cct, } if (!detail.empty()) { ostringstream ss; - ss << detail.size() << " OSDs or CRUSH {nodes, device-classes} have {NOUP,NODOWN,NOIN,NOOUT} flags set"; + ss << detail.size() << " OSDs or CRUSH nodes/device-classes have one or more of these flags set: NOUP, NODOWN, NOIN, NOOUT"; auto& d = checks->add("OSD_FLAGS", HEALTH_WARN, ss.str(), detail.size()); d.detail.swap(detail); } From f410c49e609ce883c76d7277c133f5c64a8eeb53 Mon Sep 17 00:00:00 2001 From: stzuraski898 Date: Thu, 16 Apr 2026 21:56:53 +0000 Subject: [PATCH 005/596] mgr: Properly set description in labeled get_perf_schema_python Fixes: https://tracker.ceph.com/issues/76048 Signed-off-by: stzuraski898 --- src/mgr/ActivePyModules.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mgr/ActivePyModules.cc b/src/mgr/ActivePyModules.cc index fcef67daa37..431da2c76ca 100644 --- a/src/mgr/ActivePyModules.cc +++ b/src/mgr/ActivePyModules.cc @@ -1141,7 +1141,7 @@ PyObject* ActivePyModules::get_perf_schema_python( size_t pos = type.path.rfind('.'); std::string sub_counter_name = type.path.substr(pos + 1, type.path.length()); Formatter::ObjectSection counter_section(*f, sub_counter_name); - f->create_unique("description", type.description); + f->dump_string("description", type.description); if (!type.nick.empty()) { f->dump_string("nick", type.nick); } From c6c208e67d253d8e18e5621f1eedc540b3ed699b Mon Sep 17 00:00:00 2001 From: Christopher Hoffman Date: Mon, 20 Apr 2026 16:42:20 +0000 Subject: [PATCH 006/596] qa: Add direct_io test for fscrypt Fixes: https://tracker.ceph.com/issues/73347 Signed-off-by: Christopher Hoffman --- qa/workunits/fs/misc/direct_io.py | 10 ++++ qa/workunits/fs/misc/direct_io_fscrypt.py | 64 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100755 qa/workunits/fs/misc/direct_io_fscrypt.py diff --git a/qa/workunits/fs/misc/direct_io.py b/qa/workunits/fs/misc/direct_io.py index f7d59d95a2b..4f77cf05459 100755 --- a/qa/workunits/fs/misc/direct_io.py +++ b/qa/workunits/fs/misc/direct_io.py @@ -3,11 +3,21 @@ import mmap import os import subprocess +import sys def main(): path = "testfile" + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_DIRECT, 0o644) + try: + if os.getxattr(path, "ceph.fscrypt.auth"): + os.close(fd) + print("fscrypt enabled dir, skipping!") + sys.exit(0) + except OSError: + pass + ino = os.fstat(fd).st_ino obj_name = "{ino:x}.00000000".format(ino=ino) pool_name = os.getxattr(path, "ceph.file.layout.pool") diff --git a/qa/workunits/fs/misc/direct_io_fscrypt.py b/qa/workunits/fs/misc/direct_io_fscrypt.py new file mode 100755 index 00000000000..efa2794f46a --- /dev/null +++ b/qa/workunits/fs/misc/direct_io_fscrypt.py @@ -0,0 +1,64 @@ +#!/usr/bin/python3 + +import mmap +import os +import subprocess +import sys + +def main(): + # For this test, we're going to use the underlying fscrypt libraries to + # transform data. For writes we will use file "file_in" where we write + # plain text via file and be able to read back encrypted version via object. + # Then we will use object_in where we will write encrypted data to the + # object and read the plain text back via file. + + file_in = "file_in" + object_in = "object_in" + object_out = "object_out" + + fd = os.open(file_in, os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_DIRECT, 0o644) + + fscrypt_auth = "" + + try: + fscrypt_auth = os.getxattr(file_in, "ceph.fscrypt.auth") + except OSError: + print(f"fscrypt not configured on {file_in}, exiting!") + sys.exit(0) + + ino = os.fstat(fd).st_ino + obj_name = "{ino:x}.00000000".format(ino=ino) + pool_name = os.getxattr(file_in, "ceph.file.layout.pool") + + buf = mmap.mmap(-1, 1) + buf.write(b'1') + os.write(fd, buf) + + proc = subprocess.Popen(['rados', '-p', pool_name, 'get', obj_name, object_out]) + proc.wait() + + fd2 = os.open(object_in, os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_DIRECT, 0o644) + + # override value here with same as file_in so we can decrypt + os.setxattr(object_in, "ceph.fscrypt.auth", fscrypt_auth) + + # write something other than 1 to the file + buf = mmap.mmap(-1, 1) + buf.write(b'2') + os.write(fd2, buf) + + ino2 = os.fstat(fd2).st_ino + obj_name2 = "{ino2:x}.00000000".format(ino2=ino2) + proc = subprocess.Popen(['rados', '-p', pool_name, 'put', obj_name2, object_out]) + proc.wait() + + os.lseek(fd2, 0, os.SEEK_SET) + out = os.read(fd2, 4096) + if out != b'1': + raise RuntimeError("data were not directly read from object store") + + os.close(fd) + print('ok') + + +main() From 1af9896da8f9822fcc1241344162fb9f8c97dec0 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 6 Nov 2025 20:50:22 +0000 Subject: [PATCH 007/596] os/bluestore: admin socket for bdev expand Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueAdmin.cc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/os/bluestore/BlueAdmin.cc b/src/os/bluestore/BlueAdmin.cc index 4a102a35e31..da05ecf14cb 100644 --- a/src/os/bluestore/BlueAdmin.cc +++ b/src/os/bluestore/BlueAdmin.cc @@ -3,9 +3,13 @@ #include "BlueAdmin.h" #include "Compression.h" +#include "common/errno.h" #include "common/pretty_binary.h" +#include "os/bluestore/BlueStore.h" #include "common/debug.h" #include +#include +#include #include #include @@ -78,6 +82,11 @@ BlueStore::SocketHook::SocketHook(BlueStore& store) this, "print RocksDB sharding"); ceph_assert(r == 0); + r = admin_socket->register_command("bluestore bluefs-bdev-expand", + this, + "Instruct BlueFS to check the size of its block devices" + " and, if they have expanded, make use of the additional space."); + ceph_assert(r == 0); } } @@ -287,6 +296,15 @@ int BlueStore::SocketHook::call( } } return 0; + } else if (command == "bluestore bluefs-bdev-expand"){ + std::stringstream result; + int ret = store.expand_devices(result); + if (ret < 0) { + ss << "expand device failed: " << cpp_strerror(ret) << std::endl; + } else { + out.append(result.str()); + } + return ret; } else { ss << "Invalid command" << std::endl; r = -ENOSYS; From 8703fec36f8451661f033bb33751737704d2cf2a Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 20 Nov 2025 02:01:16 +0000 Subject: [PATCH 008/596] blk: update to check new block device size Signed-off-by: Joshua Blanch --- src/blk/BlockDevice.h | 1 + src/blk/kernel/KernelDevice.cc | 33 +++++++++++++++++++++++++++++++++ src/blk/kernel/KernelDevice.h | 1 + 3 files changed, 35 insertions(+) diff --git a/src/blk/BlockDevice.h b/src/blk/BlockDevice.h index 0ea7804088e..51a39103aa4 100644 --- a/src/blk/BlockDevice.h +++ b/src/blk/BlockDevice.h @@ -241,6 +241,7 @@ public: } uint64_t get_size() const { return size; } + virtual int refresh_size() { return 0; } uint64_t get_block_size() const { return block_size; } uint64_t get_optimal_io_size() const { return optimal_io_size; } bool is_discard_supported() const { return support_discard; } diff --git a/src/blk/kernel/KernelDevice.cc b/src/blk/kernel/KernelDevice.cc index b34ad11dd7c..b61b3fe7eca 100644 --- a/src/blk/kernel/KernelDevice.cc +++ b/src/blk/kernel/KernelDevice.cc @@ -13,6 +13,7 @@ * */ +#include #include #include #include @@ -38,6 +39,7 @@ #include "common/buffer_instrumentation.h" #include "common/Clock.h" // for ceph_clock_now() #include "common/errno.h" +#include "BlockDevice.h" #if defined(__FreeBSD__) #include "bsm/audit_errno.h" #endif @@ -354,6 +356,37 @@ int KernelDevice::get_devices(std::set *ls) const return 0; } +int KernelDevice::refresh_size() +{ + if (fd_directs.empty() || fd_directs[WRITE_LIFE_NOT_SET] < 0) { + return -ENODEV; + } + + struct stat st; + int r = ::fstat(fd_directs[WRITE_LIFE_NOT_SET], &st); + if (r < 0) { + r = -errno; + derr << __func__ << " fstat failed: " << cpp_strerror(r) << dendl; + return r; + } + + // logic taken from open() for block vs file + int64_t new_size; + if (S_ISBLK(st.st_mode)) { + BlkDev blkdev(fd_directs[WRITE_LIFE_NOT_SET]); + r = blkdev.get_size(&new_size); + if (r < 0) { + derr << __func__ << " ioctl failed: " << cpp_strerror(r) << dendl; + return r; + } + } else { + new_size = st.st_size; + } + + size = new_size; + return 0; +} + void KernelDevice::close() { dout(1) << __func__ << dendl; diff --git a/src/blk/kernel/KernelDevice.h b/src/blk/kernel/KernelDevice.h index fb206f6ba2d..81f576f59ff 100644 --- a/src/blk/kernel/KernelDevice.h +++ b/src/blk/kernel/KernelDevice.h @@ -146,6 +146,7 @@ public: return 0; } int get_devices(std::set *ls) const override; + int refresh_size() override; int get_ebd_state(ExtBlkDevState &state) const override; int detect_ebd(std::string& id) override; From bd6c72e01dac8d7cef1abfe68807dac6c81b6b33 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 20 Nov 2025 18:31:23 +0000 Subject: [PATCH 009/596] os/bluestore: implement expand() for bitmap allocator hierarchy Implements device expansion support for bitmap-based allocators BitmapAllocator and HybridAllocator. Bitmap allocators uses allocated fixed-size bit vectors (L0, L1, L2) and requires vector resizing and metadata updates when underlying device expands. Some different cases that are handled: - padding reclamation when alignment boundaries change - Sub-granularity expansions (L2 doesn't grow, only L2 bits update) To be used such as expand() followed by init_add_free() to ensure available space accounting happens once. Signed-off-by: Joshua Blanch --- src/os/bluestore/Allocator.h | 6 +- src/os/bluestore/BitmapAllocator.cc | 20 ++++ src/os/bluestore/BitmapAllocator.h | 2 + src/os/bluestore/HybridAllocator.h | 16 +++ src/os/bluestore/fastbmap_allocator_impl.h | 85 ++++++++++++++ .../objectstore/fastbmap_allocator_test.cc | 107 ++++++++++++++++++ 6 files changed, 235 insertions(+), 1 deletion(-) diff --git a/src/os/bluestore/Allocator.h b/src/os/bluestore/Allocator.h index 17e6d47af2a..df563764330 100644 --- a/src/os/bluestore/Allocator.h +++ b/src/os/bluestore/Allocator.h @@ -90,9 +90,13 @@ public: { return block_size; } + virtual void expand(int64_t new_size){ + ceph_assert(new_size >= device_size); + device_size = new_size; + } protected: - const int64_t device_size = 0; + int64_t device_size = 0; const int64_t block_size = 0; }; diff --git a/src/os/bluestore/BitmapAllocator.cc b/src/os/bluestore/BitmapAllocator.cc index 3ab43bea344..8c59c2b1dd0 100644 --- a/src/os/bluestore/BitmapAllocator.cc +++ b/src/os/bluestore/BitmapAllocator.cc @@ -2,6 +2,7 @@ // vim: ts=8 sw=2 sts=2 expandtab #include "BitmapAllocator.h" +#include #include "include/types.h" // for byte_u_t #define dout_context cct @@ -89,6 +90,25 @@ void BitmapAllocator::init_rm_free(uint64_t offset, uint64_t length) ldout(cct, 10) << __func__ << " done" << dendl; } +void BitmapAllocator::expand(int64_t new_size) +{ + int64_t old_size = get_capacity(); + ceph_assert(new_size >= old_size); + + if (new_size == old_size) { + return; + } + + ldout(cct, 1) << __func__ << " expanding from 0x" << std::hex << old_size + << " to 0x" << new_size << std::dec << dendl; + + Allocator::expand(new_size); + AllocatorLevel02::expand(new_size, old_size); + + ldout(cct, 1) << __func__ << " expansion complete, available=0x" + << std::hex << get_available() << std::dec << dendl; +} + void BitmapAllocator::shutdown() { ldout(cct, 1) << __func__ << dendl; diff --git a/src/os/bluestore/BitmapAllocator.h b/src/os/bluestore/BitmapAllocator.h index 8cad1f2fcd6..65976eb3c8a 100644 --- a/src/os/bluestore/BitmapAllocator.h +++ b/src/os/bluestore/BitmapAllocator.h @@ -56,6 +56,8 @@ public: void init_add_free(uint64_t offset, uint64_t length) override; void init_rm_free(uint64_t offset, uint64_t length) override; + void expand(int64_t new_size) override; + void shutdown() override; }; diff --git a/src/os/bluestore/HybridAllocator.h b/src/os/bluestore/HybridAllocator.h index 9617f51a0da..528c61ee06f 100644 --- a/src/os/bluestore/HybridAllocator.h +++ b/src/os/bluestore/HybridAllocator.h @@ -56,6 +56,22 @@ public: } } void init_rm_free(uint64_t offset, uint64_t length) override; + + void expand(int64_t new_size) override { + std::lock_guard l(PrimaryAllocator::get_lock()); + int64_t old_size = PrimaryAllocator::get_capacity(); + ceph_assert(new_size >= old_size); + + if (new_size == old_size) { + return; + } + + PrimaryAllocator::expand(new_size); + if (bmap_alloc) { + bmap_alloc->expand(new_size); + } + } + void shutdown() override { std::lock_guard l(PrimaryAllocator::get_lock()); PrimaryAllocator::_shutdown(); diff --git a/src/os/bluestore/fastbmap_allocator_impl.h b/src/os/bluestore/fastbmap_allocator_impl.h index fbf3fda0b32..f22882f7880 100644 --- a/src/os/bluestore/fastbmap_allocator_impl.h +++ b/src/os/bluestore/fastbmap_allocator_impl.h @@ -295,6 +295,48 @@ protected: } } + void expand(uint64_t new_capacity, uint64_t old_capacity) + { + auto new_aligned_capacity = + p2roundup((int64_t)new_capacity, + int64_t(l1_granularity * slots_per_slotset * _children_per_slot())); + + auto old_aligned_capacity = + p2roundup((int64_t)old_capacity, + int64_t(l1_granularity * slots_per_slotset * _children_per_slot())); + + if (new_aligned_capacity == old_aligned_capacity) { + // aligned capacity didn't change, but we should check the old and new boundaries + auto old_l0_pos_no_use = p2roundup((int64_t)old_capacity, (int64_t)l0_granularity) / l0_granularity; + auto new_l0_pos_no_use = p2roundup((int64_t)new_capacity, (int64_t)l0_granularity) / l0_granularity; + if (old_l0_pos_no_use < new_l0_pos_no_use) { + // L1 aligned capacity is the same but actual capacity increased + // mark the newly expanded region [old_capacity, new_capacity) as free + _mark_free_l1_l0(old_l0_pos_no_use, new_l0_pos_no_use); + } + return; + } + + size_t new_slot_count = new_aligned_capacity / l1_granularity / _children_per_slot(); + size_t old_slot_count = l1.size(); + l1.resize(new_slot_count, all_slot_set); + + size_t new_slot_count_l0 = new_aligned_capacity / l0_granularity / bits_per_slot; + l0.resize(new_slot_count_l0, all_slot_set); + + unalloc_l1_count += (new_slot_count - old_slot_count) * _children_per_slot(); + + // Free the old padding at the old aligned boundary + auto old_l0_pos_no_use = p2roundup((int64_t)old_capacity, (int64_t)l0_granularity) / l0_granularity; + auto old_l0_pos_end = old_aligned_capacity / l0_granularity; + if (old_l0_pos_no_use < old_l0_pos_end) { + _mark_free_l1_l0(old_l0_pos_no_use, old_l0_pos_end); + } + + auto l0_pos_no_use = p2roundup((int64_t)new_capacity, (int64_t)l0_granularity) / l0_granularity; + _mark_alloc_l1_l0(l0_pos_no_use, new_aligned_capacity / l0_granularity); + } + struct search_ctx_t { size_t partial_count = 0; @@ -596,6 +638,49 @@ public: return l1.get_fragmentation(); } + void expand(uint64_t new_capacity, uint64_t old_capacity) + { + ceph_assert(new_capacity >= old_capacity); + std::lock_guard l(lock); + + l1.expand(new_capacity, old_capacity); + + auto new_aligned_capacity = + p2roundup((int64_t)new_capacity, (int64_t)l2_granularity * L1_ENTRIES_PER_SLOT); + auto old_aligned_capacity = + p2roundup((int64_t)old_capacity, (int64_t)l2_granularity * L1_ENTRIES_PER_SLOT); + + if (new_aligned_capacity == old_aligned_capacity) { + // L2 vector didn't grow, but we need to update L2 bits to reflect the expanded L1 entries + // calculate which L2 position range covers the expanded region [old_capacity, new_capacity) + auto l2_pos_start = old_capacity / l2_granularity; + auto l2_pos_end = p2roundup((int64_t)new_capacity, (int64_t)l2_granularity) / l2_granularity; + + if (l2_pos_start < l2_pos_end) { + // update L2 metadata for the expanded region by examining L1 state + _mark_l2_on_l1(l2_pos_start, l2_pos_end); + } + + return; + } + + size_t new_elem_count = new_aligned_capacity / l2_granularity / L1_ENTRIES_PER_SLOT; + l2.resize(new_elem_count, all_slot_set); + + // free the old padding so we can reuse that space + auto old_l2_pos_no_use = + p2roundup((int64_t)old_capacity, (int64_t)l2_granularity) / l2_granularity; + auto old_l2_pos_end = old_aligned_capacity / l2_granularity; + if (old_l2_pos_no_use < old_l2_pos_end) { + _mark_l2_free(old_l2_pos_no_use, old_l2_pos_end); + } + + auto l2_pos_no_use = + p2roundup((int64_t)new_capacity, (int64_t)l2_granularity) / l2_granularity; + _mark_l2_allocated(l2_pos_no_use, new_aligned_capacity / l2_granularity); + + } + protected: ceph::mutex lock = ceph::make_mutex("AllocatorLevel02::lock"); L1 l1; diff --git a/src/test/objectstore/fastbmap_allocator_test.cc b/src/test/objectstore/fastbmap_allocator_test.cc index 83babab567f..7f763d49c72 100644 --- a/src/test/objectstore/fastbmap_allocator_test.cc +++ b/src/test/objectstore/fastbmap_allocator_test.cc @@ -31,6 +31,10 @@ public: { _init(capacity, alloc_unit); } + void expand(uint64_t new_capacity, uint64_t old_capacity) + { + AllocatorLevel02::expand(new_capacity, old_capacity); + } void allocate_l2(uint64_t length, uint64_t min_length, uint64_t* allocated0, interval_vector_t* res) @@ -1143,3 +1147,106 @@ TEST(TestAllocatorLevel01, test_claim_free_l2) ASSERT_EQ(0x15000, al2.debug_get_free()); } + +TEST(TestAllocatorLevel02, test_expand_simple) +{ + TestAllocatorLevel02 al2; + uint64_t capacity = 64 * 1024 * 1024; // 64MiB + uint64_t block_size = 0x1000; // 4KB + + al2.init(capacity, block_size); + ASSERT_EQ(capacity, al2.debug_get_free()); + ASSERT_EQ(capacity, al2.get_available()); + interval_vector_t r1; + uint64_t allocated = 0; + uint64_t alloc_size = 60 * 1024 * 1024; + al2.allocate_l2(alloc_size, block_size, &allocated, &r1); + ASSERT_EQ(allocated, alloc_size); + + uint64_t free_before = al2.debug_get_free(); + uint64_t available_before = al2.get_available(); + std::cout << "Before expand: free=" << free_before + << " available=" << available_before << std::endl; + + uint64_t new_capacity = 128 * 1024 * 1024; + al2.expand(new_capacity, capacity); + al2.mark_free(capacity, new_capacity - capacity); + uint64_t free_after = al2.debug_get_free(); + uint64_t available_after = al2.get_available(); + uint64_t expected_increase = new_capacity - capacity; + + std::cout << "After expand: free=" << free_after + << " available=" << available_after << std::endl; + std::cout << "Expected increase: " << expected_increase << std::endl; + std::cout << "Actual increase: " << (free_after - free_before) << std::endl; + + ASSERT_EQ(free_after, free_before + expected_increase); + ASSERT_EQ(available_after, available_before + expected_increase); + + // allocate more than the original capacity would allow + // this must use the expanded region since total = 110MB > 64MB original + interval_vector_t r2; + allocated = 0; + uint64_t second_alloc = 50 * 1024 * 1024; // 50MiB + al2.allocate_l2(second_alloc, block_size, &allocated, &r2); + ASSERT_EQ(allocated, second_alloc); + + std::cout << "Allocated " << (second_alloc / 1024 / 1024) << "MB in " + << r2.size() << " intervals:" << std::endl; + + // verify at least some allocation came from expanded region + bool uses_expanded = false; + for (const auto& interval : r2) { + std::cout << " offset=0x" << std::hex << interval.offset + << " length=0x" << interval.length << std::dec << std::endl; + // check if allocation starts in or extends into expanded region + // either starts in expanded region or spans into it + if (interval.offset >= capacity || interval.offset + interval.length > capacity) { + uses_expanded = true; + if (interval.offset >= capacity) { + std::cout << " ^ Allocation starts in expanded region" << std::endl; + } else { + std::cout << " ^ Allocation spans into expanded region" << std::endl; + } + } + } + + ASSERT_TRUE(uses_expanded); + + // should have ~18MiB free (128MiB - 60MiB - 50MiB) + uint64_t free_final = al2.debug_get_free(); + uint64_t expected_free = new_capacity - alloc_size - second_alloc; + std::cout << "Final free: " << free_final + << " expected: " << expected_free << std::endl; + ASSERT_EQ(free_final, expected_free); +} + +TEST(TestAllocatorLevel02, test_expand_multiple_times) +{ + TestAllocatorLevel02 al2; + uint64_t capacity = 32 * 1024 * 1024; + uint64_t block_size = 0x1000; + + al2.init(capacity, block_size); + ASSERT_EQ(capacity, al2.debug_get_free()); + + uint64_t capacity2 = 64 * 1024 * 1024; + al2.expand(capacity2, capacity); + al2.mark_free(capacity, capacity2 - capacity); + ASSERT_EQ(capacity2, al2.debug_get_free()); + ASSERT_EQ(capacity2, al2.get_available()); + + uint64_t capacity3 = 128 * 1024 * 1024; + al2.expand(capacity3, capacity2); + al2.mark_free(capacity2, capacity3 - capacity2); + ASSERT_EQ(capacity3, al2.debug_get_free()); + ASSERT_EQ(capacity3, al2.get_available()); + + interval_vector_t r; + uint64_t allocated = 0; + al2.allocate_l2(100 * 1024 * 1024, block_size, &allocated, &r); + ASSERT_EQ(allocated, 100 * 1024 * 1024); + + uint64_t free_final = al2.debug_get_free(); + ASSERT_EQ(free_final, 28 * 1024 * 1024); // 128MiB - 100MiB +} From 530faae4a56c47f617aab8f3627b4353df56964b Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 20 Nov 2025 18:49:15 +0000 Subject: [PATCH 010/596] os/bluestore: expose expand() method for FreelistManager Signed-off-by: Joshua Blanch --- src/os/bluestore/BitmapFreelistManager.cc | 6 ++++++ src/os/bluestore/BitmapFreelistManager.h | 1 + src/os/bluestore/FreelistManager.h | 1 + 3 files changed, 8 insertions(+) diff --git a/src/os/bluestore/BitmapFreelistManager.cc b/src/os/bluestore/BitmapFreelistManager.cc index 92768fb03f9..ea6f95f45ec 100644 --- a/src/os/bluestore/BitmapFreelistManager.cc +++ b/src/os/bluestore/BitmapFreelistManager.cc @@ -163,6 +163,12 @@ int BitmapFreelistManager::_expand(uint64_t old_size, KeyValueDB* db) return 0; } +int BitmapFreelistManager::expand(uint64_t new_size, KeyValueDB* db){ + uint64_t old_size = size; + size = new_size; + return _expand(old_size ,db); +} + int BitmapFreelistManager::read_size_meta_from_db(KeyValueDB* kvdb, uint64_t* res) { diff --git a/src/os/bluestore/BitmapFreelistManager.h b/src/os/bluestore/BitmapFreelistManager.h index 943f6ce6ab4..5127eef3435 100644 --- a/src/os/bluestore/BitmapFreelistManager.h +++ b/src/os/bluestore/BitmapFreelistManager.h @@ -96,6 +96,7 @@ public: std::vector>*) const override; bool validate(uint64_t min_alloc_size) const override; + int expand(uint64_t new_size, KeyValueDB* db) override; }; #endif diff --git a/src/os/bluestore/FreelistManager.h b/src/os/bluestore/FreelistManager.h index f369a9b2be3..e8a3a624d15 100644 --- a/src/os/bluestore/FreelistManager.h +++ b/src/os/bluestore/FreelistManager.h @@ -49,6 +49,7 @@ public: virtual uint64_t get_alloc_units() const = 0; virtual uint64_t get_alloc_size() const = 0; + virtual int expand(uint64_t new_size, KeyValueDB* db) = 0; virtual void get_meta(uint64_t target_size, std::vector>*) const = 0; From d7bd19a50871dacdf7f86d62b212fbb9f6d6263c Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 20 Nov 2025 18:52:55 +0000 Subject: [PATCH 011/596] bluestore/bluefs: add expand_device for online device usage adds support for expanding the device capacity while fs is online Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueFS.cc | 23 +++++++++++++++++++++++ src/os/bluestore/BlueFS.h | 2 ++ 2 files changed, 25 insertions(+) diff --git a/src/os/bluestore/BlueFS.cc b/src/os/bluestore/BlueFS.cc index 19032f5ecbc..b17bf772093 100644 --- a/src/os/bluestore/BlueFS.cc +++ b/src/os/bluestore/BlueFS.cc @@ -924,6 +924,29 @@ void BlueFS::_stop_alloc() } } +void BlueFS::expand_device(unsigned devid, uint64_t new_size, uint64_t old_size) +{ + dout(10) << __func__ << " dev " << devid + << " from 0x" << std::hex << old_size + << " to 0x" << new_size << std::dec << dendl; + + ceph_assert(devid < alloc.size()); + ceph_assert(alloc[devid]); + ceph_assert(new_size > old_size); + + alloc[devid]->expand(new_size); + auto aligned_size = p2align(new_size, alloc_size[devid]); + alloc[devid]->init_add_free(old_size, aligned_size - old_size); + + uint64_t total = get_block_device_size(devid); + uint64_t free = get_free(devid); + ceph_assert(total > total - free); + + dout(10) << __func__ << " dev " << devid + << " added free space: 0x" << std::hex << old_size + << "~0x" << (aligned_size - old_size) << std::dec << dendl; +} + int BlueFS::_read_and_check(uint8_t ndev, uint64_t off, uint64_t len, ceph::buffer::list *pbl, IOContext *ioc, bool buffered) { diff --git a/src/os/bluestore/BlueFS.h b/src/os/bluestore/BlueFS.h index 7295fd47869..30542966fb6 100644 --- a/src/os/bluestore/BlueFS.h +++ b/src/os/bluestore/BlueFS.h @@ -901,6 +901,8 @@ public: bool bdev_support_label(unsigned id); BlockDevice* get_block_device(unsigned bdev) const; + void expand_device(unsigned devid, uint64_t new_size, uint64_t old_size); + // handler for discard event void handle_discard(unsigned dev, interval_set& to_release); From 57e3d3bb344735bbef78604ecb77c31bec51bdf6 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 20 Nov 2025 20:00:37 +0000 Subject: [PATCH 012/596] os/bluestore: add asok for online device expansion expands bluestore and bluefs while OSD is running. Instead of recreating allocators, expands allocator and FM in-place. ceph tell osd.X bluestore bluefs-bdev-expand Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueAdmin.cc | 2 +- src/os/bluestore/BlueStore.cc | 136 ++++++++++++++++++++++++++++++++++ src/os/bluestore/BlueStore.h | 1 + 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/os/bluestore/BlueAdmin.cc b/src/os/bluestore/BlueAdmin.cc index da05ecf14cb..e647b7bb85e 100644 --- a/src/os/bluestore/BlueAdmin.cc +++ b/src/os/bluestore/BlueAdmin.cc @@ -298,7 +298,7 @@ int BlueStore::SocketHook::call( return 0; } else if (command == "bluestore bluefs-bdev-expand"){ std::stringstream result; - int ret = store.expand_devices(result); + int ret = store.expand_devices_online(result); if (ret < 0) { ss << "expand device failed: " << cpp_strerror(ret) << std::endl; } else { diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 4e869411049..46b8ccaa6f9 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9323,6 +9323,142 @@ bool BlueStore::get_db_sharding(std::string& res_sharding) return ret; } +int BlueStore::expand_devices_online(ostream& out) +{ + bluefs->dump_block_extents(out); + out << "Expanding DB/WAL..." << std::endl; + + // updating dedicated devices first + for (auto devid : { BlueFS::BDEV_WAL, BlueFS::BDEV_DB}) { + if (devid == bluefs_layout.shared_bdev) { + continue; + } + + auto my_bdev = bluefs->get_block_device(devid); + my_bdev->refresh_size(); + uint64_t size = my_bdev ? my_bdev->get_size() : 0; + + if (size == 0) { + // no bdev + continue; + } + if (my_bdev->supported_bdev_label()) { + string my_path = get_device_path(devid); + bluestore_bdev_label_t my_label; + int r = _read_bdev_label(cct, my_bdev, my_path, &my_label); + if (r < 0) { + derr << "unable to read label for " << my_path << ": " + << cpp_strerror(r) << dendl; + continue; + } else { + if (size == my_label.size) { + // no need to expand + out << devid + << " : nothing to do, skipped" + << std::endl; + continue; + } else if (size < my_label.size) { + // something weird in bdev label + out << devid + <<" : ERROR: bdev label is above device size, skipped" + << std::endl; + continue; + } else { + int64_t old_size = my_label.size; + my_label.size = size; + out << devid + << " : Expanding to 0x" << std::hex << size + << std::dec << "(" << byte_u_t(size) << ")" + << std::endl; + r = _write_bdev_label(cct, my_bdev, my_path, my_label); + if (r < 0) { + derr << "unable to write label for " << my_path << ": " + << cpp_strerror(r) << dendl; + } else { + out << devid + << " : size updated to 0x" << std::hex << size + << std::dec << "(" << byte_u_t(size) << ")" + << std::endl; + } + bluefs->expand_device(devid, size, old_size); + } + } + } + } + auto devid = bluefs_layout.shared_bdev; + // bluestore and bluefs hold separate instances so we need to + // refresh both to figure out if there is a new size + bluefs->get_block_device(devid)->refresh_size(); + bdev->refresh_size(); + + uint64_t size0 = fm->get_size(); + uint64_t size = bdev->get_size(); + auto aligned_size = p2align(size, min_alloc_size); + int r = 0; + if (aligned_size == size0) { + // no need to expand + out << devid + << " : nothing to do, skipped" + << std::endl; + } else if (aligned_size < size0) { + // something weird in bdev label + out << devid + << " : ERROR: previous device size is above the current one, skipped" + << std::endl; + } else { + auto my_path = get_device_path(devid); + out << devid + <<" : Expanding to 0x" << std::hex << size + << std::dec << "(" << byte_u_t(size) << ")" + << std::endl; + r = _write_out_fm_meta(size); + if (r != 0) { + derr << "unable to write out fm meta for " << my_path << ": " + << cpp_strerror(r) << dendl; + } else if (bdev->supported_bdev_label()) { + bdev_label.size = size; + uint64_t lsize = std::max(BDEV_LABEL_BLOCK_SIZE, min_alloc_size); + for (uint64_t loc : bdev_label_positions) { + if ((loc >= size0) && (loc + lsize <= size)) { + bdev_label_valid_locations.push_back(loc); + if (!bdev_label_multi) { + break; + } + } + } + r = _write_bdev_label(cct, bdev, my_path, + bdev_label, bdev_label_valid_locations); + } + if (r == 0) { + out << devid + << " : size updated to 0x" << std::hex << size + << std::dec << "(" << byte_u_t(size) << ")" + << std::endl; + + // since we are not relying on db open/close to update FM + // need to keep FM and the shared allocator in sync now + fm->expand(aligned_size, db); + alloc->expand(aligned_size); + alloc->init_add_free(size0, aligned_size - size0); + + need_to_destage_allocation_file = true; + before_expansion_bdev_size = 0; + + dout(1) << __func__ + << " : size updated to 0x" << std::hex << size + << std::dec << "(" << byte_u_t(size) << ")" + << ", allocator type " << alloc->get_type() + << ", capacity 0x" << alloc->get_capacity() + << ", block size 0x" << alloc->get_block_size() + << ", free 0x" << alloc->get_free() + << ", fragmentation " << alloc->get_fragmentation() + << std::dec << dendl; + + } + } + return r; +} + int BlueStore::dump_bluefs_sizes(ostream& out) { int r = _open_db_and_around(true); diff --git a/src/os/bluestore/BlueStore.h b/src/os/bluestore/BlueStore.h index 19111a19432..b00ceab5c5e 100644 --- a/src/os/bluestore/BlueStore.h +++ b/src/os/bluestore/BlueStore.h @@ -3207,6 +3207,7 @@ public: int id, const std::string& path); int expand_devices(std::ostream& out); + int expand_devices_online(std::ostream& out); std::string get_device_path(unsigned id); bool get_db_sharding(std::string& res_sharding); From a201ee80fbd0d1a0cd92c08a9b147ad8e393fc2d Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 23 Dec 2025 22:37:52 +0000 Subject: [PATCH 013/596] blk/BlockDevice: changes block device size as atomic Signed-off-by: Joshua Blanch --- src/blk/BlockDevice.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blk/BlockDevice.h b/src/blk/BlockDevice.h index 51a39103aa4..58e2afacb75 100644 --- a/src/blk/BlockDevice.h +++ b/src/blk/BlockDevice.h @@ -179,7 +179,7 @@ private: void *cbpriv, aio_callback_t d_cb, void *d_cbpriv, const char* dev_name); protected: - uint64_t size = 0; + std::atomic size = 0; uint64_t block_size = 0; uint64_t optimal_io_size = 0; bool support_discard = false; From 2fc6f819411957d09614d5ca44a71c9773d3aca4 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 6 Jan 2026 18:55:45 +0000 Subject: [PATCH 014/596] os/bluestore: update bluefs volume selector Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueFS.cc | 5 +++++ src/os/bluestore/BlueFS.h | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/os/bluestore/BlueFS.cc b/src/os/bluestore/BlueFS.cc index b17bf772093..3144538fd30 100644 --- a/src/os/bluestore/BlueFS.cc +++ b/src/os/bluestore/BlueFS.cc @@ -938,6 +938,11 @@ void BlueFS::expand_device(unsigned devid, uint64_t new_size, uint64_t old_size) auto aligned_size = p2align(new_size, alloc_size[devid]); alloc[devid]->init_add_free(old_size, aligned_size - old_size); + if (vselector) { + vselector->expand_device(devid, aligned_size); + vselector->update_from_config(cct); + } + uint64_t total = get_block_device_size(devid); uint64_t free = get_free(devid); ceph_assert(total > total - free); diff --git a/src/os/bluestore/BlueFS.h b/src/os/bluestore/BlueFS.h index 30542966fb6..094f5e8dcf3 100644 --- a/src/os/bluestore/BlueFS.h +++ b/src/os/bluestore/BlueFS.h @@ -211,6 +211,15 @@ public: /* used for sanity checking of vselector */ virtual BlueFSVolumeSelector* clone_empty() const { return nullptr; } virtual bool compare(BlueFSVolumeSelector* other) { return true; }; + + /** + * Update device total size after online expansion + * Parameters: + * dev_id: BlueFS device id (BDEV_WAL, BDEV_DB, BDEV_SLOW) + * new_size: new total size of the device + * + */ + virtual void expand_device(uint8_t dev_id, uint64_t new_size) = 0; }; struct bluefs_shared_alloc_context_t { @@ -1010,6 +1019,22 @@ public: // do nothing return; } + + void expand_device(uint8_t dev_id, uint64_t new_size) override { + switch (dev_id) { + case BlueFS::BDEV_WAL: + wal_total = new_size; + break; + case BlueFS::BDEV_DB: + db_total = new_size; + break; + case BlueFS::BDEV_SLOW: + slow_total = new_size; + break; + default: + break; + } + } }; class FitToFastVolumeSelector : public OriginalVolumeSelector { @@ -1259,6 +1284,22 @@ public: void dump(std::ostream& sout) override; BlueFSVolumeSelector* clone_empty() const override; bool compare(BlueFSVolumeSelector* other) override; + + void expand_device(uint8_t dev_id, uint64_t new_size) override { + switch (dev_id) { + case BlueFS::BDEV_WAL: + l_totals[LEVEL_WAL - LEVEL_FIRST] = new_size; + break; + case BlueFS::BDEV_DB: + l_totals[LEVEL_DB - LEVEL_FIRST] = new_size; + break; + case BlueFS::BDEV_SLOW: + l_totals[LEVEL_SLOW - LEVEL_FIRST] = new_size; + break; + default: + break; + } + } }; /** From 2ab1311f38f33bd573a4299d546b2252fb7898bf Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 6 Jan 2026 23:58:24 +0000 Subject: [PATCH 015/596] os/bluestore: refactors expand_devices for online and offline use Refactors device expansion to use a expand_devices() that handles both online (admin socket) and offline (ceph-bluestore-tool) use cases. Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueAdmin.cc | 2 +- src/os/bluestore/BlueStore.cc | 176 +++++++--------------------------- src/os/bluestore/BlueStore.h | 1 - 3 files changed, 35 insertions(+), 144 deletions(-) diff --git a/src/os/bluestore/BlueAdmin.cc b/src/os/bluestore/BlueAdmin.cc index e647b7bb85e..da05ecf14cb 100644 --- a/src/os/bluestore/BlueAdmin.cc +++ b/src/os/bluestore/BlueAdmin.cc @@ -298,7 +298,7 @@ int BlueStore::SocketHook::call( return 0; } else if (command == "bluestore bluefs-bdev-expand"){ std::stringstream result; - int ret = store.expand_devices_online(result); + int ret = store.expand_devices(result); if (ret < 0) { ss << "expand device failed: " << cpp_strerror(ret) << std::endl; } else { diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 46b8ccaa6f9..f93342b2b82 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9184,135 +9184,6 @@ string BlueStore::get_device_path(unsigned id) return res; } -int BlueStore::expand_devices(ostream& out) -{ - // let's open in read-only mode first to be able to recover - // from the out-of-space state at DB/shared volume(s) - // Opening in R/W mode might cause extra space allocation - // which is effectively a show stopper for volume expansion. - int r = _open_db_and_around(true); - ceph_assert(r == 0); - bluefs->dump_block_extents(out); - out << "Expanding DB/WAL..." << std::endl; - // updating dedicated devices first - for (auto devid : { BlueFS::BDEV_WAL, BlueFS::BDEV_DB}) { - if (devid == bluefs_layout.shared_bdev) { - continue; - } - auto my_bdev = bluefs->get_block_device(devid); - uint64_t size = my_bdev ? my_bdev->get_size() : 0; - if (size == 0) { - // no bdev - continue; - } - if (my_bdev->supported_bdev_label()) { - string my_path = get_device_path(devid); - bluestore_bdev_label_t my_label; - int r = _read_bdev_label(cct, my_bdev, my_path, &my_label); - if (r < 0) { - derr << "unable to read label for " << my_path << ": " - << cpp_strerror(r) << dendl; - continue; - } else { - if (size == my_label.size) { - // no need to expand - out << devid - << " : nothing to do, skipped" - << std::endl; - continue; - } else if (size < my_label.size) { - // something weird in bdev label - out << devid - <<" : ERROR: bdev label is above device size, skipped" - << std::endl; - continue; - } else { - my_label.size = size; - out << devid - << " : Expanding to 0x" << std::hex << size - << std::dec << "(" << byte_u_t(size) << ")" - << std::endl; - r = _write_bdev_label(cct, my_bdev, my_path, my_label); - if (r < 0) { - derr << "unable to write label for " << my_path << ": " - << cpp_strerror(r) << dendl; - } else { - out << devid - << " : size updated to 0x" << std::hex << size - << std::dec << "(" << byte_u_t(size) << ")" - << std::endl; - } - } - } - } - } - // now proceed with a shared device - uint64_t size0 = fm->get_size(); - uint64_t size = bdev->get_size(); - auto devid = bluefs_layout.shared_bdev; - auto aligned_size = p2align(size, min_alloc_size); - if (aligned_size == size0) { - // no need to expand - out << devid - << " : nothing to do, skipped" - << std::endl; - } else if (aligned_size < size0) { - // something weird in bdev label - out << devid - << " : ERROR: previous device size is above the current one, skipped" - << std::endl; - } else { - auto my_path = get_device_path(devid); - out << devid - <<" : Expanding to 0x" << std::hex << size - << std::dec << "(" << byte_u_t(size) << ")" - << std::endl; - r = _write_out_fm_meta(size); - if (r != 0) { - derr << "unable to write out fm meta for " << my_path << ": " - << cpp_strerror(r) << dendl; - } else if (bdev->supported_bdev_label()) { - bdev_label.size = size; - uint64_t lsize = std::max(BDEV_LABEL_BLOCK_SIZE, min_alloc_size); - for (uint64_t loc : bdev_label_positions) { - if ((loc >= size0) && (loc + lsize <= size)) { - bdev_label_valid_locations.push_back(loc); - if (!bdev_label_multi) { - break; - } - } - } - r = _write_bdev_label(cct, bdev, my_path, - bdev_label, bdev_label_valid_locations); - if (r != 0) { - derr << "unable to write label(s) for " << my_path << ": " - << cpp_strerror(r) << dendl; - } - } - if (r == 0) { - out << devid - << " : size updated to 0x" << std::hex << size - << std::dec << "(" << byte_u_t(size) << ")" - << std::endl; - _close_db_and_around(); - - // - // Mount in read/write to sync expansion changes - // and make sure everything is all right. - // - before_expansion_bdev_size = size0; // preserve orignal size to permit - // following _db_open_and_around() - // do some post-init stuff on opened - // allocator. - - r = _open_db_and_around(false); - ceph_assert(r == 0); - } - } - _close_db_and_around(); - return r; -} - bool BlueStore::get_db_sharding(std::string& res_sharding) { bool ret = false; @@ -9323,8 +9194,24 @@ bool BlueStore::get_db_sharding(std::string& res_sharding) return ret; } -int BlueStore::expand_devices_online(ostream& out) +int BlueStore::expand_devices(ostream& out) { + bool need_to_close = false; + int r = 0; + + if (!mounted) { + // let's open in read-only mode first to be able to recover + // from the out-of-space state at DB/shared volume(s) + // Opening in R/W mode might cause extra space allocation + // which is effectively a show stopper for volume expansion. + r = _open_db_and_around(false); + if (r < 0) { + derr << __func__ << " failed to open db: " << cpp_strerror(r) << dendl; + return r; + } + need_to_close = true; + } + bluefs->dump_block_extents(out); out << "Expanding DB/WAL..." << std::endl; @@ -9345,7 +9232,7 @@ int BlueStore::expand_devices_online(ostream& out) if (my_bdev->supported_bdev_label()) { string my_path = get_device_path(devid); bluestore_bdev_label_t my_label; - int r = _read_bdev_label(cct, my_bdev, my_path, &my_label); + r = _read_bdev_label(cct, my_bdev, my_path, &my_label); if (r < 0) { derr << "unable to read label for " << my_path << ": " << cpp_strerror(r) << dendl; @@ -9380,7 +9267,9 @@ int BlueStore::expand_devices_online(ostream& out) << std::dec << "(" << byte_u_t(size) << ")" << std::endl; } - bluefs->expand_device(devid, size, old_size); + if (mounted) { + bluefs->expand_device(devid, size, old_size); + } } } } @@ -9394,7 +9283,7 @@ int BlueStore::expand_devices_online(ostream& out) uint64_t size0 = fm->get_size(); uint64_t size = bdev->get_size(); auto aligned_size = p2align(size, min_alloc_size); - int r = 0; + r = 0; if (aligned_size == size0) { // no need to expand out << devid @@ -9410,7 +9299,7 @@ int BlueStore::expand_devices_online(ostream& out) out << devid <<" : Expanding to 0x" << std::hex << size << std::dec << "(" << byte_u_t(size) << ")" - << std::endl; + << std::endl; r = _write_out_fm_meta(size); if (r != 0) { derr << "unable to write out fm meta for " << my_path << ": " @@ -9428,6 +9317,10 @@ int BlueStore::expand_devices_online(ostream& out) } r = _write_bdev_label(cct, bdev, my_path, bdev_label, bdev_label_valid_locations); + if (r != 0) { + derr << "unable to write label(s) for " << my_path << ": " + << cpp_strerror(r) << dendl; + } } if (r == 0) { out << devid @@ -9435,27 +9328,26 @@ int BlueStore::expand_devices_online(ostream& out) << std::dec << "(" << byte_u_t(size) << ")" << std::endl; - // since we are not relying on db open/close to update FM - // need to keep FM and the shared allocator in sync now fm->expand(aligned_size, db); alloc->expand(aligned_size); alloc->init_add_free(size0, aligned_size - size0); - need_to_destage_allocation_file = true; - before_expansion_bdev_size = 0; - dout(1) << __func__ << " : size updated to 0x" << std::hex << size << std::dec << "(" << byte_u_t(size) << ")" << ", allocator type " << alloc->get_type() - << ", capacity 0x" << alloc->get_capacity() + << ", capacity 0x" << std::hex << alloc->get_capacity() << ", block size 0x" << alloc->get_block_size() << ", free 0x" << alloc->get_free() + << std::dec << ", fragmentation " << alloc->get_fragmentation() - << std::dec << dendl; - + << dendl; } } + + if (need_to_close) { + _close_db_and_around(); + } return r; } diff --git a/src/os/bluestore/BlueStore.h b/src/os/bluestore/BlueStore.h index b00ceab5c5e..19111a19432 100644 --- a/src/os/bluestore/BlueStore.h +++ b/src/os/bluestore/BlueStore.h @@ -3207,7 +3207,6 @@ public: int id, const std::string& path); int expand_devices(std::ostream& out); - int expand_devices_online(std::ostream& out); std::string get_device_path(unsigned id); bool get_db_sharding(std::string& res_sharding); From ba5fb6bef28d9cf1af7183565b0793ff36b18b4c Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Wed, 7 Jan 2026 00:13:28 +0000 Subject: [PATCH 016/596] qa/standalone/bluefs: add online device expansion test verify online device expansion via admin socket works correctly Signed-off-by: Joshua Blanch --- qa/standalone/osd/osd-bluefs-volume-ops.sh | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/qa/standalone/osd/osd-bluefs-volume-ops.sh b/qa/standalone/osd/osd-bluefs-volume-ops.sh index 0f521524363..75f39935ad7 100755 --- a/qa/standalone/osd/osd-bluefs-volume-ops.sh +++ b/qa/standalone/osd/osd-bluefs-volume-ops.sh @@ -487,6 +487,87 @@ function TEST_bluestore_expand() { ceph-bluestore-tool --log-file $dir/bluestore_tool.log --path $dir/0 qfsck || return 1 } +function TEST_bluestore_expand_online() { + local dir=$1 + + local flimit=$(ulimit -n) + if [ $flimit -lt 1536 ]; then + echo "Low open file limit ($flimit), test may fail. Increase to 1536 or higher and retry if that happens." + fi + export CEPH_MON="127.0.0.1:7146" # git grep '\<7146\>' : there must be only one + export CEPH_ARGS + CEPH_ARGS+="--fsid=$(uuidgen) --auth-supported=none " + CEPH_ARGS+="--mon-host=$CEPH_MON " + CEPH_ARGS+="--bluestore_block_size=2147483648 " + CEPH_ARGS+="--bluestore_block_db_create=true " + CEPH_ARGS+="--bluestore_block_db_size=536870912 " + CEPH_ARGS+="--bluestore_block_wal_create=true " + CEPH_ARGS+="--bluestore_block_wal_size=268435456 " + CEPH_ARGS+="--bluestore_fsck_on_mount=true " + + run_mon $dir a || return 1 + run_mgr $dir x || return 1 + run_osd $dir 0 || return 1 + osd_pid0=$(cat $dir/osd.0.pid) + run_osd $dir 1 || return 1 + osd_pid1=$(cat $dir/osd.1.pid) + run_osd $dir 2 || return 1 + osd_pid2=$(cat $dir/osd.2.pid) + run_osd $dir 3 || return 1 + osd_pid3=$(cat $dir/osd.3.pid) + + sleep 5 + create_pool foo 16 + + timeout 60 rados bench -p foo 30 write -b 4096 --no-cleanup #|| return 1 + + # expand slow devices while OSDs are running + truncate $dir/0/block -s 4294967296 # 4GB + ceph tell osd.0 bluestore bluefs-bdev-expand || return 1 + + truncate $dir/1/block -s 11811160064 # 11GB + ceph tell osd.1 bluestore bluefs-bdev-expand || return 1 + + truncate $dir/2/block -s 4295099392 # 4GB + 129KB + ceph tell osd.2 bluestore bluefs-bdev-expand || return 1 + + truncate $dir/3/block -s 4293918720 # 4GB - 1MB + ceph tell osd.3 bluestore bluefs-bdev-expand || return 1 + + # expand DB devices while OSDs are running + truncate $dir/0/block.db -s 1073741824 # 1GB + ceph tell osd.0 bluestore bluefs-bdev-expand || return 1 + + truncate $dir/1/block.db -s 1073741824 # 1GB + ceph tell osd.1 bluestore bluefs-bdev-expand || return 1 + + # write more objects to use the new space + timeout 60 rados bench -p foo 30 write -b 4096 --no-cleanup #|| return 1 + + wait_for_clean || return 1 + + ceph tell osd.0 bluestore bluefs device info + ceph tell osd.1 bluestore bluefs device info + ceph tell osd.2 bluestore bluefs device info + ceph tell osd.3 bluestore bluefs device info + + # kill and verify with fsck + while kill $osd_pid0; do sleep 1 ; done + ceph osd down 0 + while kill $osd_pid1; do sleep 1 ; done + ceph osd down 1 + while kill $osd_pid2; do sleep 1 ; done + ceph osd down 2 + while kill $osd_pid3; do sleep 1 ; done + ceph osd down 3 + + ceph-bluestore-tool --path $dir/0 fsck || return 1 + ceph-bluestore-tool --path $dir/1 fsck || return 1 + ceph-bluestore-tool --path $dir/2 fsck || return 1 + ceph-bluestore-tool --path $dir/3 fsck || return 1 + +} + main osd-bluefs-volume-ops "$@" # Local Variables: From 36d005e02d29a6b42840d46bb3b6170a81b38328 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 05:44:00 +0000 Subject: [PATCH 017/596] os/bluestore: make Allocator::device_size atomic and protect BitmapAllocator::expand Signed-off-by: Joshua Blanch --- src/os/bluestore/Allocator.h | 9 +++++---- src/os/bluestore/BitmapAllocator.cc | 7 ++++--- src/os/bluestore/BitmapAllocator.h | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/os/bluestore/Allocator.h b/src/os/bluestore/Allocator.h index df563764330..000c7471ef8 100644 --- a/src/os/bluestore/Allocator.h +++ b/src/os/bluestore/Allocator.h @@ -13,6 +13,7 @@ #ifndef CEPH_OS_BLUESTORE_ALLOCATOR_H #define CEPH_OS_BLUESTORE_ALLOCATOR_H +#include #include #include #include "include/ceph_assert.h" @@ -84,19 +85,19 @@ public: virtual const std::string& get_name() const = 0; int64_t get_capacity() const { - return device_size; + return device_size.load(); } int64_t get_block_size() const { return block_size; } virtual void expand(int64_t new_size){ - ceph_assert(new_size >= device_size); - device_size = new_size; + ceph_assert(new_size >= device_size.load()); + device_size.store(new_size); } protected: - int64_t device_size = 0; + std::atomic device_size{0}; const int64_t block_size = 0; }; diff --git a/src/os/bluestore/BitmapAllocator.cc b/src/os/bluestore/BitmapAllocator.cc index 8c59c2b1dd0..09cc78f2eed 100644 --- a/src/os/bluestore/BitmapAllocator.cc +++ b/src/os/bluestore/BitmapAllocator.cc @@ -57,7 +57,7 @@ void BitmapAllocator::release( for (auto& [offset, len] : release_set) { ldout(cct, 10) << __func__ << " 0x" << std::hex << offset << "~" << len << std::dec << dendl; - ceph_assert(offset + len <= (uint64_t)device_size); + ceph_assert(offset + len <= (uint64_t)device_size.load()); } } _free_l2(release_set); @@ -73,7 +73,7 @@ void BitmapAllocator::init_add_free(uint64_t offset, uint64_t length) auto mas = get_min_alloc_size(); uint64_t offs = round_up_to(offset, mas); uint64_t l = p2align(offset + length - offs, mas); - ceph_assert(offs + l <= (uint64_t)device_size); + ceph_assert(offs + l <= (uint64_t)device_size.load()); _mark_free(offs, l); ldout(cct, 10) << __func__ << " done" << dendl; @@ -85,13 +85,14 @@ void BitmapAllocator::init_rm_free(uint64_t offset, uint64_t length) auto mas = get_min_alloc_size(); uint64_t offs = round_up_to(offset, mas); uint64_t l = p2align(offset + length - offs, mas); - ceph_assert(offs + l <= (uint64_t)device_size); + ceph_assert(offs + l <= (uint64_t)device_size.load()); _mark_allocated(offs, l); ldout(cct, 10) << __func__ << " done" << dendl; } void BitmapAllocator::expand(int64_t new_size) { + std::lock_guard l(expand_lock); int64_t old_size = get_capacity(); ceph_assert(new_size >= old_size); diff --git a/src/os/bluestore/BitmapAllocator.h b/src/os/bluestore/BitmapAllocator.h index 65976eb3c8a..3961af228f6 100644 --- a/src/os/bluestore/BitmapAllocator.h +++ b/src/os/bluestore/BitmapAllocator.h @@ -16,6 +16,7 @@ class BitmapAllocator : public AllocatorBase, public AllocatorLevel02 { CephContext* cct; + ceph::mutex expand_lock = ceph::make_mutex("BitmapAllocator::expand_lock"); public: BitmapAllocator(CephContext* _cct, int64_t capacity, int64_t alloc_unit, std::string_view name); From 0eb1b730d1656f1513e3ccd45c729cdf8435cbc6 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 18:13:34 +0000 Subject: [PATCH 018/596] qa/standalone: bdev-expand calls with no underlying device expansion Signed-off-by: Joshua Blanch --- qa/standalone/osd/osd-bluefs-volume-ops.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qa/standalone/osd/osd-bluefs-volume-ops.sh b/qa/standalone/osd/osd-bluefs-volume-ops.sh index 75f39935ad7..8f4f14cf14a 100755 --- a/qa/standalone/osd/osd-bluefs-volume-ops.sh +++ b/qa/standalone/osd/osd-bluefs-volume-ops.sh @@ -519,6 +519,12 @@ function TEST_bluestore_expand_online() { sleep 5 create_pool foo 16 + # no device expansion sanity check + ceph tell osd.0 bluestore bluefs-bdev-expand || return 1 + ceph tell osd.1 bluestore bluefs-bdev-expand || return 1 + ceph tell osd.2 bluestore bluefs-bdev-expand || return 1 + ceph tell osd.3 bluestore bluefs-bdev-expand || return 1 + timeout 60 rados bench -p foo 30 write -b 4096 --no-cleanup #|| return 1 # expand slow devices while OSDs are running From 2186c8bec2acaf716a271bc714ec5b377a2f8f1e Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 18:27:22 +0000 Subject: [PATCH 019/596] os/bluestore: skip bitmapfm exapnd if old and new size are the same Signed-off-by: Joshua Blanch --- src/os/bluestore/BitmapFreelistManager.cc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/os/bluestore/BitmapFreelistManager.cc b/src/os/bluestore/BitmapFreelistManager.cc index ea6f95f45ec..9df22a97007 100644 --- a/src/os/bluestore/BitmapFreelistManager.cc +++ b/src/os/bluestore/BitmapFreelistManager.cc @@ -164,6 +164,9 @@ int BitmapFreelistManager::_expand(uint64_t old_size, KeyValueDB* db) } int BitmapFreelistManager::expand(uint64_t new_size, KeyValueDB* db){ + if (new_size == size){ + return 0; + } uint64_t old_size = size; size = new_size; return _expand(old_size ,db); From 12eaa37f7b87e654d868591eff4f714b663c09ab Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 18:57:54 +0000 Subject: [PATCH 020/596] os/bluestore: remove unused if branch Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index f93342b2b82..fb2311611e8 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9267,9 +9267,7 @@ int BlueStore::expand_devices(ostream& out) << std::dec << "(" << byte_u_t(size) << ")" << std::endl; } - if (mounted) { - bluefs->expand_device(devid, size, old_size); - } + bluefs->expand_device(devid, size, old_size); } } } From 17f2979774d845985bb35c3a4bc54a2f6b050a31 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 18:58:51 +0000 Subject: [PATCH 021/596] os/bluestore: rounds size0 up such that expand is [p2roundup(size0), aligned_size) Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index fb2311611e8..90c78c71c4f 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9328,7 +9328,8 @@ int BlueStore::expand_devices(ostream& out) fm->expand(aligned_size, db); alloc->expand(aligned_size); - alloc->init_add_free(size0, aligned_size - size0); + uint64_t aligned_size0 = p2roundup(size0, min_alloc_size); + alloc->init_add_free(aligned_size0, aligned_size - aligned_size0); dout(1) << __func__ << " : size updated to 0x" << std::hex << size From 5962c7e8e901089703ba9f75fff697c2fea20c76 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 19:34:16 +0000 Subject: [PATCH 022/596] os/bluestore: round old size by min_alloc Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueFS.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/os/bluestore/BlueFS.cc b/src/os/bluestore/BlueFS.cc index 3144538fd30..3b19b21c618 100644 --- a/src/os/bluestore/BlueFS.cc +++ b/src/os/bluestore/BlueFS.cc @@ -16,6 +16,7 @@ #include "Allocator.h" #include "include/buffer_fwd.h" #include "include/ceph_assert.h" +#include "include/intarith.h" #include "include/stringify.h" #include "common/admin_socket.h" #include "os/bluestore/bluefs_types.h" @@ -936,7 +937,8 @@ void BlueFS::expand_device(unsigned devid, uint64_t new_size, uint64_t old_size) alloc[devid]->expand(new_size); auto aligned_size = p2align(new_size, alloc_size[devid]); - alloc[devid]->init_add_free(old_size, aligned_size - old_size); + auto aligned_old_size = p2roundup(old_size, alloc_size[devid]); + alloc[devid]->init_add_free(aligned_old_size, aligned_size - aligned_old_size); if (vselector) { vselector->expand_device(devid, aligned_size); From 8d1dadb90cef1a3f1feb53b459502a486bccc5cd Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Tue, 3 Mar 2026 19:37:06 +0000 Subject: [PATCH 023/596] os/bluestore: adds assert for device mounted before expand Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 90c78c71c4f..b31462d8975 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9267,6 +9267,7 @@ int BlueStore::expand_devices(ostream& out) << std::dec << "(" << byte_u_t(size) << ")" << std::endl; } + ceph_assert(mounted); bluefs->expand_device(devid, size, old_size); } } From 60197ef72c219137c3f3c6747fdf3181f0d7e5ac Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Thu, 5 Mar 2026 16:25:59 +0000 Subject: [PATCH 024/596] os/bluestore: fix nullptr derefence for bdev in single-device setup Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index b31462d8975..1d5bc522b80 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9222,6 +9222,9 @@ int BlueStore::expand_devices(ostream& out) } auto my_bdev = bluefs->get_block_device(devid); + if (!my_bdev) { + continue; + } my_bdev->refresh_size(); uint64_t size = my_bdev ? my_bdev->get_size() : 0; From 4f93487e2d48f843bb30d8fe1015ddf718ba0c47 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Mon, 16 Mar 2026 17:29:14 +0000 Subject: [PATCH 025/596] os/bluestore: open bluestore in read only mode during device expansion Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 1d5bc522b80..9aaec54cf88 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9204,11 +9204,8 @@ int BlueStore::expand_devices(ostream& out) // from the out-of-space state at DB/shared volume(s) // Opening in R/W mode might cause extra space allocation // which is effectively a show stopper for volume expansion. - r = _open_db_and_around(false); - if (r < 0) { - derr << __func__ << " failed to open db: " << cpp_strerror(r) << dendl; - return r; - } + r = _open_db_and_around(true); + ceph_assert(r == 0); need_to_close = true; } From b8b8fa97073ebb4248aca62c143d5947eec779b1 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Mon, 16 Mar 2026 20:31:04 +0000 Subject: [PATCH 026/596] os/bluestore: dout free space added starts at aligned_old_size Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueFS.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/os/bluestore/BlueFS.cc b/src/os/bluestore/BlueFS.cc index 3b19b21c618..8bd8fff46cd 100644 --- a/src/os/bluestore/BlueFS.cc +++ b/src/os/bluestore/BlueFS.cc @@ -950,8 +950,8 @@ void BlueFS::expand_device(unsigned devid, uint64_t new_size, uint64_t old_size) ceph_assert(total > total - free); dout(10) << __func__ << " dev " << devid - << " added free space: 0x" << std::hex << old_size - << "~0x" << (aligned_size - old_size) << std::dec << dendl; + << " added free space: 0x" << std::hex << aligned_old_size + << "~0x" << (aligned_size - aligned_old_size) << std::dec << dendl; } int BlueFS::_read_and_check(uint8_t ndev, uint64_t off, uint64_t len, From 439e1b04dfb12fbf5633b893620d697dc614b78d Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Mon, 13 Apr 2026 02:20:39 +0000 Subject: [PATCH 027/596] os/bluestore: fix offline device expansion in expand_devices guard bluefs->expand_device for online-only fixes offline expansion by reopening db from r/o to r/w fixes int64_t/uint64_t type mismatch for old_size Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 9aaec54cf88..49e78cebc10 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9251,7 +9251,7 @@ int BlueStore::expand_devices(ostream& out) << std::endl; continue; } else { - int64_t old_size = my_label.size; + uint64_t old_size = my_label.size; my_label.size = size; out << devid << " : Expanding to 0x" << std::hex << size @@ -9266,9 +9266,12 @@ int BlueStore::expand_devices(ostream& out) << " : size updated to 0x" << std::hex << size << std::dec << "(" << byte_u_t(size) << ")" << std::endl; + // online expand needs to update allocator now + // offline does not need this as update will happen + // on next open + if (!need_to_close) + bluefs->expand_device(devid, size, old_size); } - ceph_assert(mounted); - bluefs->expand_device(devid, size, old_size); } } } @@ -9327,6 +9330,12 @@ int BlueStore::expand_devices(ostream& out) << std::dec << "(" << byte_u_t(size) << ")" << std::endl; + if (need_to_close) { + _close_db_and_around(); + r = _open_db_and_around(false); + ceph_assert(r == 0); + } + fm->expand(aligned_size, db); alloc->expand(aligned_size); uint64_t aligned_size0 = p2roundup(size0, min_alloc_size); From bd412d1f175948f4cbd7fd22977d21a1279df3d5 Mon Sep 17 00:00:00 2001 From: Joshua Blanch Date: Mon, 13 Apr 2026 17:20:30 +0000 Subject: [PATCH 028/596] os/bluestore: restore previous offline expansion logic expanding allocator before opening for offline path fixes to comments and null check Signed-off-by: Joshua Blanch --- src/os/bluestore/BlueStore.cc | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index 49e78cebc10..3d49feff4e7 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -9223,7 +9223,7 @@ int BlueStore::expand_devices(ostream& out) continue; } my_bdev->refresh_size(); - uint64_t size = my_bdev ? my_bdev->get_size() : 0; + uint64_t size = my_bdev->get_size(); if (size == 0) { // no bdev @@ -9266,7 +9266,7 @@ int BlueStore::expand_devices(ostream& out) << " : size updated to 0x" << std::hex << size << std::dec << "(" << byte_u_t(size) << ")" << std::endl; - // online expand needs to update allocator now + // online expand needs to update BlueFS allocator(s) now // offline does not need this as update will happen // on next open if (!need_to_close) @@ -9332,15 +9332,23 @@ int BlueStore::expand_devices(ostream& out) if (need_to_close) { _close_db_and_around(); + // + // Mount in read/write to sync expansion changes + // and make sure everything is all right. + // + before_expansion_bdev_size = size0; // preserve orignal size to permit + // following _db_open_and_around() + // do some post-init stuff on opened + // allocator. r = _open_db_and_around(false); ceph_assert(r == 0); + } else { + fm->expand(aligned_size, db); + alloc->expand(aligned_size); + uint64_t aligned_size0 = p2roundup(size0, min_alloc_size); + alloc->init_add_free(aligned_size0, aligned_size - aligned_size0); } - fm->expand(aligned_size, db); - alloc->expand(aligned_size); - uint64_t aligned_size0 = p2roundup(size0, min_alloc_size); - alloc->init_add_free(aligned_size0, aligned_size - aligned_size0); - dout(1) << __func__ << " : size updated to 0x" << std::hex << size << std::dec << "(" << byte_u_t(size) << ")" From 2599fc10c5e25c7543538cf15f822241f6151fec Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 8 Dec 2025 18:06:40 +0530 Subject: [PATCH 029/596] volumes/stats_util: improve log messages Signed-off-by: Rishabh Dave --- src/pybind/mgr/volumes/fs/stats_util.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pybind/mgr/volumes/fs/stats_util.py b/src/pybind/mgr/volumes/fs/stats_util.py index 683ffc2df85..6c5d8f8705a 100644 --- a/src/pybind/mgr/volumes/fs/stats_util.py +++ b/src/pybind/mgr/volumes/fs/stats_util.py @@ -47,6 +47,7 @@ def get_amount_copied(src_path, dst_path, fs_handle): try: size_t = int(fs_handle.getxattr(src_path, rbytes)) + log.debug(f'rbytes on path src_path ({src_path}) = {size_t}') except ObjectNotFound: log.info(f'get_amount_copied(): source path "{src_path}" went missing, ' 'couldn\'t run getxattr on it') @@ -54,6 +55,7 @@ def get_amount_copied(src_path, dst_path, fs_handle): try: size_c = int(fs_handle.getxattr(dst_path, rbytes)) + log.debug(f'rbytes on path dst_path ({dst_path}) = {size_c}') except ObjectNotFound: log.info(f'get_amount_copied(): destination path "{dst_path}" went ' 'missing, couldn\'t run getxattr on it') @@ -210,8 +212,8 @@ class CloneProgressReporter: # get clone in order in which they were launched, this # should be same as the ctime on clone entry. clone_index_entries = clone_index.list_entries_by_ctime_order() - log.debug('finished collecting all clone index entries, ' - f'found {len(clones)} clone index entries') + log.debug(f'found {len(clone_index_entries)} clone index ' + 'entries') # reset ongoing clone counter before iterating over all clone # entries @@ -248,8 +250,7 @@ class CloneProgressReporter: clones.append(ci) - log.debug('finished collecting info on all clones, found ' - f'{len(clones)} clones out of which ' + log.debug(f'found {len(clones)} clones, out of which ' f'{self.ongoing_clones_count} are ongoing clones') return clones From 269e76bb139d3c28092249edf1b92dfbfaf38c93 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 8 Dec 2025 18:07:09 +0530 Subject: [PATCH 030/596] qa/cephfs: increase number of files to cloned in test_clone_stats.py There's a race condition between cloning operation and clone progress reporter thread to open the subvolume, former does so to copy/clone files and latter to get details of the source and destination subvolume. A low number of files in the snapshot gives clone progress reporter thread less or no chance to fetch and process the details needed to display the progress bar. This causes tests to fails when progress bar isn't displayed in the output of "ceph status" output. Increasing the number of files will give clone progress reporter more time to display the progress bar and therefore these unnecessary test failures wlll reduce/stop. This failure wasn't seen earlier because Python code was being used to copy files which was comparatively slow (compared to C++ code that was merged to copy files as a part of fscrypt work) which allowed enough time to clone progress reporter thread to process stats and display clone progress bar. Fixes: https://tracker.ceph.com/issues/74082 Signed-off-by: Rishabh Dave --- qa/tasks/cephfs/volumes/test_clone_stats.py | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/qa/tasks/cephfs/volumes/test_clone_stats.py b/qa/tasks/cephfs/volumes/test_clone_stats.py index ce4c4709829..5b5c41ee32f 100644 --- a/qa/tasks/cephfs/volumes/test_clone_stats.py +++ b/qa/tasks/cephfs/volumes/test_clone_stats.py @@ -332,7 +332,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): c = 'ss1clone1' self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -369,7 +369,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): c = 'ss1clone1' self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 10, 1024) + size = self._do_subvolume_io(sv, None, None, 100, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -417,7 +417,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.run_ceph_cmd(f'fs subvolumegroup create {v} {group}') self.run_ceph_cmd(f'fs subvolume create {v} {sv} {group} --mode=777') - size = self._do_subvolume_io(sv, group, None, 10, 1024) + size = self._do_subvolume_io(sv, group, None, 100, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss} {group}') self.wait_till_rbytes_is_right(v, sv, size, group) @@ -469,7 +469,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.config_set('mds', 'mds_snap_rstat', 'true') self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 10, 1024) + size = self._do_subvolume_io(sv, None, None, 100, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -513,7 +513,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): c = self._gen_subvol_clone_name(4) self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 10, 1024) + size = self._do_subvolume_io(sv, None, None, 100, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -563,7 +563,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.config_set('mgr', 'mgr/volumes/snapshot_clone_no_wait', 'false') self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -614,7 +614,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.config_set('mgr', 'mgr/volumes/snapshot_clone_no_wait', 'false') self.run_ceph_cmd(f'fs subvolume create {v} {sv} --mode=777') - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -673,7 +673,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): sv_path = self.get_ceph_cmd_stdout(f'fs subvolume getpath {v} {sv}') sv_path = sv_path[1:] - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -716,7 +716,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): sv_path = self.get_ceph_cmd_stdout(f'fs subvolume getpath {v} {sv}') sv_path = sv_path[1:] - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -763,7 +763,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): sv_path = self.get_ceph_cmd_stdout(f'fs subvolume getpath {v} {sv}') sv_path = sv_path[1:] - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) @@ -814,7 +814,7 @@ class TestOngoingClonesCounter(CloneProgressReporterHelper): sv_path = self.get_ceph_cmd_stdout(f'fs subvolume getpath {v} {sv}') sv_path = sv_path[1:] - size = self._do_subvolume_io(sv, None, None, 3, 1024) + size = self._do_subvolume_io(sv, None, None, 30, 100) self.run_ceph_cmd(f'fs subvolume snapshot create {v} {sv} {ss}') self.wait_till_rbytes_is_right(v, sv, size) From 241cfc6c06d11a8245222a20d4ed615c8dbcb63b Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 8 Dec 2025 18:24:47 +0530 Subject: [PATCH 031/596] qa/cephfs: give more time to tests in test_clone_stats.py Currently tests wait for 10 seconds for clone progress bar to appear and it usually takes 8 seconds to find it. In case of the host where test is being run is slow, the progress bar can take more time to appear and the test can fail unnecessarily. To avoid this, increase the waiting duration. Signed-off-by: Rishabh Dave --- qa/tasks/cephfs/volumes/test_clone_stats.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qa/tasks/cephfs/volumes/test_clone_stats.py b/qa/tasks/cephfs/volumes/test_clone_stats.py index 5b5c41ee32f..299107cfdad 100644 --- a/qa/tasks/cephfs/volumes/test_clone_stats.py +++ b/qa/tasks/cephfs/volumes/test_clone_stats.py @@ -376,7 +376,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.run_ceph_cmd(f'fs subvolume snapshot clone {v} {sv} {ss} {c}') - with safe_while(tries=10, sleep=1) as proceed: + with safe_while(tries=10, sleep=2) as proceed: while proceed(): pev = self.get_pevs_from_ceph_status(c) @@ -425,7 +425,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): self.run_ceph_cmd(f'fs subvolume snapshot clone {v} {sv} {ss} {c} ' f'--group-name {group}') - with safe_while(tries=10, sleep=1) as proceed: + with safe_while(tries=10, sleep=2) as proceed: while proceed(): pev = self.get_pevs_from_ceph_status(c) @@ -521,7 +521,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): for i in c: self.run_ceph_cmd(f'fs subvolume snapshot clone {v} {sv} {ss} {i}') - with safe_while(tries=10, sleep=1) as proceed: + with safe_while(tries=10, sleep=2) as proceed: while proceed(): pev = self.get_pevs_from_ceph_status(c) @@ -573,7 +573,7 @@ class TestCloneProgressReporter(CloneProgressReporterHelper): msg = ('messages for progress bars for snapshot cloning are not how ' 'they were expected') - with safe_while(tries=20, sleep=1, action=msg) as proceed: + with safe_while(tries=20, sleep=2, action=msg) as proceed: while proceed(): pevs = self.get_pevs_from_ceph_status(c) From c75b8eefd7bcb8dcf91aaa8fb8bcfd1fe9c9a6ca Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 8 Dec 2025 18:27:59 +0530 Subject: [PATCH 032/596] qa/cephfs: minor fix in comment Signed-off-by: Rishabh Dave --- qa/tasks/cephfs/volumes/test_clone_stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qa/tasks/cephfs/volumes/test_clone_stats.py b/qa/tasks/cephfs/volumes/test_clone_stats.py index 299107cfdad..194dfafed99 100644 --- a/qa/tasks/cephfs/volumes/test_clone_stats.py +++ b/qa/tasks/cephfs/volumes/test_clone_stats.py @@ -797,7 +797,7 @@ class TestOngoingClonesCounter(CloneProgressReporterHelper): ''' Class CloneProgressReporter contains the code that lets it figure out the number of ongoing clones on its own, without referring the MGR config - option mgr/volumes/max_concurrenr_clones. This class contains tests to + option mgr/volumes/max_concurrent_clones. This class contains tests to ensure that this code, that does the figuring out, is working fine. ''' From 8499124ada51858d788387bcf7c24178368aa95d Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Wed, 3 Dec 2025 09:28:08 +0100 Subject: [PATCH 033/596] mgr/cephadm: plumb force_delete_data through daemon/service removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR wires the `force_delete_data` already existing flag in the binary through cephadm’s daemon and service removal paths, so that commands such as `ceph orch rm service` or equivalent daemon removal can explicitly ask for data deletion instead of the default "move under /removed/" for daemons such as Prometheus, osd and mon. Fixes: https://tracker.ceph.com/issues/74058 Signed-off-by: Kobi Ginon --- doc/cephadm/services/index.rst | 7 +++ doc/cephadm/services/monitoring.rst | 7 ++- doc/mgr/orchestrator.rst | 11 +++- src/pybind/mgr/cephadm/inventory.py | 19 +++--- src/pybind/mgr/cephadm/module.py | 64 ++++++++++++++++---- src/pybind/mgr/cephadm/serve.py | 22 ++++--- src/pybind/mgr/cephadm/tests/test_cephadm.py | 44 ++++++++++++++ src/pybind/mgr/orchestrator/_interface.py | 4 +- src/pybind/mgr/orchestrator/module.py | 60 +++++++++++++++--- 9 files changed, 195 insertions(+), 43 deletions(-) diff --git a/doc/cephadm/services/index.rst b/doc/cephadm/services/index.rst index dfe737f83d2..05566235128 100644 --- a/doc/cephadm/services/index.rst +++ b/doc/cephadm/services/index.rst @@ -804,6 +804,13 @@ For example: ceph orch rm rgw.myrgw +The same command accepts ``--force`` and ``--force-delete-data``. Use +``ceph orch rm --force --force-delete-data`` when you want +cephadm to remove on-disk data for supported daemon types instead of relocating +it under ``/removed/``. The latter flag requires ``--force``. The same +pair of flags applies to ``ceph orch daemon rm `` when removing +individual daemons. + .. _cephadm-spec-unmanaged: diff --git a/doc/cephadm/services/monitoring.rst b/doc/cephadm/services/monitoring.rst index d89818cf9e1..8552e2316be 100644 --- a/doc/cephadm/services/monitoring.rst +++ b/doc/cephadm/services/monitoring.rst @@ -463,11 +463,16 @@ To disable monitoring and remove the software that supports it, run the followin .. prompt:: bash # ceph orch rm grafana - ceph orch rm prometheus --force # this will delete metrics data collected so far + ceph orch rm prometheus ceph orch rm node-exporter ceph orch rm alertmanager ceph mgr module disable prometheus +By default, cephadm moves Prometheus data aside under ``/removed/`` on the +host. To delete that data instead, use +``ceph orch rm prometheus --force --force-delete-data`` in place of +``ceph orch rm prometheus`` (``--force-delete-data`` requires ``--force``). + See also :ref:`orch-rm`. Setting up RBD-Image Monitoring diff --git a/doc/mgr/orchestrator.rst b/doc/mgr/orchestrator.rst index c3fd5bee405..154015c1ff7 100644 --- a/doc/mgr/orchestrator.rst +++ b/doc/mgr/orchestrator.rst @@ -113,10 +113,19 @@ Creating/growing/shrinking/removing services: ceph orch apply mds [--placement=] [--dry-run] ceph orch apply rgw [--realm=] [--zone=] [--port=] [--ssl] [--placement=] [--dry-run] ceph orch apply nfs [--namespace=] [--placement=] [--dry-run] - ceph orch rm [--force] + ceph orch rm [--force] [--force-delete-data] where ``placement`` is a :ref:`orchestrator-cli-placement-spec`. +For ``ceph orch rm``, ``--force`` may be required in some cases (for example when +removing an OSD service that would leave OSDs behind). + +The ``--force-delete-data`` flag requires that ``--force`` is also passed. +This directs cephadm to delete on-disk data for certain daemon types instead +of moving it to ``/removed/`` on the host. These daemon types include +``mon``, ``osd``, and ``prometheus``. This helps avoid filling up the +underlying filesystem over time. + e.g., ``ceph orch apply mds myfs --placement="3 host1 host2 host3"`` Service Commands: diff --git a/src/pybind/mgr/cephadm/inventory.py b/src/pybind/mgr/cephadm/inventory.py index aed6ba03efa..cdb75d2b75f 100644 --- a/src/pybind/mgr/cephadm/inventory.py +++ b/src/pybind/mgr/cephadm/inventory.py @@ -239,7 +239,7 @@ class SpecDescription(NamedTuple): spec: ServiceSpec rank_map: Optional[Dict[int, Dict[int, Optional[str]]]] created: datetime.datetime - deleted: Optional[datetime.datetime] + deleted: Optional[Tuple[datetime.datetime, bool]] class SpecStore(): @@ -250,7 +250,7 @@ class SpecStore(): # service_name -> rank -> gen -> daemon_id self._rank_maps = {} # type: Dict[str, Dict[int, Dict[int, Optional[str]]]] self.spec_created = {} # type: Dict[str, datetime.datetime] - self.spec_deleted = {} # type: Dict[str, datetime.datetime] + self.spec_deleted = {} # type: Dict[str, Tuple[datetime.datetime, bool]] self.spec_preview = {} # type: Dict[str, ServiceSpec] self._needs_configuration: Dict[str, bool] = {} @@ -315,8 +315,11 @@ class SpecStore(): self.spec_created[service_name] = created if 'deleted' in j: - deleted = str_to_datetime(cast(str, j['deleted'])) - self.spec_deleted[service_name] = deleted + deleted_ts = str_to_datetime(cast(str, j['deleted'])) + force_delete_data = cast( + bool, j.get('force_delete_data', False) + ) + self.spec_deleted[service_name] = (deleted_ts, force_delete_data) if 'needs_configuration' in j: self._needs_configuration[service_name] = cast(bool, j['needs_configuration']) @@ -378,7 +381,9 @@ class SpecStore(): if name in self._rank_maps: data['rank_map'] = self._rank_maps[name] if name in self.spec_deleted: - data['deleted'] = datetime_to_str(self.spec_deleted[name]) + deleted_time, force_delete_data = self.spec_deleted[name] + data['deleted'] = datetime_to_str(deleted_time) + data['force_delete_data'] = force_delete_data if name in self._needs_configuration: data['needs_configuration'] = self._needs_configuration[name] @@ -469,7 +474,7 @@ class SpecStore(): service_name=nvmeof_spec.service_name(), user_made=True) - def rm(self, service_name: str) -> bool: + def rm(self, service_name: str, force_delete_data: bool = False) -> bool: if service_name not in self._specs: return False @@ -477,7 +482,7 @@ class SpecStore(): self.finally_rm(service_name) return True - self.spec_deleted[service_name] = datetime_now() + self.spec_deleted[service_name] = (datetime_now(), force_delete_data) self.save(self._specs[service_name], update_create=False) return True diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index e6704e8a023..e631d71b62b 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -2494,13 +2494,14 @@ Then run the following: else: size = spec.placement.get_target_count(self.cache.get_schedulable_hosts()) + deleted_ts = self.spec_store.spec_deleted.get(nm) svc_desc = orchestrator.ServiceDescription( spec=spec, size=size, running=0, events=self.events.get_for_service(spec.service_name()), created=self.spec_store.spec_created[nm], - deleted=self.spec_store.spec_deleted.get(nm, None), + deleted=deleted_ts[0] if deleted_ts else None, virtual_ip=spec.get_virtual_ip(), ports=spec.get_port_start(), ) @@ -2786,28 +2787,61 @@ Then run the following: return msg @handle_orch_error - def remove_daemons(self, names): - # type: (List[str]) -> List[str] + def remove_daemons(self, + names: List[str], + force_delete_data: bool = False) -> List[str]: + """ + Remove specific daemon(s). + + :param names: daemon names to remove + :param force: skip safety checks (PRECIOUS DATA warning) + :param force_delete_data: if True, request that cephadm delete the + daemon data instead of moving it under + /removed/. + """ args = [] for host, dm in self.cache.daemons.items(): for name in names: if name in dm: - args.append((name, host)) + args.append((name, host, force_delete_data)) if not args: raise OrchestratorError('Unable to find daemon(s) %s' % (names), errno=errno.EINVAL) self.log.info('Remove daemons %s' % ' '.join([a[0] for a in args])) return self._remove_daemons(args) @handle_orch_error - def remove_service(self, service_name: str, force: bool = False) -> str: - self.log.info('Remove service %s' % service_name) + def remove_service( + self, + service_name: str, + force: bool = False, + force_delete_data: bool = False + ) -> str: + """ + Remove a service. + + :param service_name: service to remove + :param force: skip safety checks (e.g., leftover OSDs) + :param force_delete_data: intent to delete backing daemon data instead + of moving it under /removed/. + (actual effect depends on lower layers) + """ + self.log.info( + 'Remove service %s (force=%s, force_delete_data=%s)' % + (service_name, force, force_delete_data) + ) self._trigger_preview_refresh(service_name=service_name) if service_name in self.spec_store: if self.spec_store[service_name].spec.service_type in ('mon', 'mgr'): - return f'Unable to remove {service_name} service.\n' \ - f'Note, you might want to mark the {service_name} service as "unmanaged"' + return ( + f'Unable to remove {service_name} service.\n' + f'Note, you might want to mark the {service_name} ' + f'service as "unmanaged"' + ) else: - return f"Invalid service '{service_name}'. Use 'ceph orch ls' to list available services.\n" + return ( + f"Invalid service '{service_name}'. Use 'ceph orch ls' to " + f"list available services.\n" + ) # Report list of affected OSDs? if not force and service_name.startswith('osd.'): @@ -2824,9 +2858,11 @@ Then run the following: for h, ls in osds_msg.items(): msg += f'\thost {h}: {" ".join([f"osd.{id}" for id in ls])}' raise OrchestratorError( - f'If {service_name} is removed then the following OSDs will remain, --force to proceed anyway\n{msg}') + f'If {service_name} is removed then the following OSDs ' + f'will remain, --force to proceed anyway\n{msg}' + ) - found = self.spec_store.rm(service_name) + found = self.spec_store.rm(service_name, force_delete_data) if found and service_name.startswith('osd.'): self.spec_store.finally_rm(service_name) self._kick_serve_loop() @@ -3244,8 +3280,10 @@ Then run the following: return previews_for_specs @forall_hosts - def _remove_daemons(self, name: str, host: str) -> str: - return CephadmServe(self)._remove_daemon(name, host) + def _remove_daemons(self, name: str, host: str, force_delete_data: bool = False) -> str: + # pass force_delete_data by keyword: third positional arg is no_post_remove + return CephadmServe(self)._remove_daemon( + name, host, force_delete_data=force_delete_data) def _check_pool_exists(self, pool: str, service_name: str) -> None: logger.info(f'Checking pool "{pool}" exists for service {service_name}') diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index c98321e8111..75e7a8aa5d4 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1116,15 +1116,16 @@ class CephadmServe: if not spec and dd.daemon_type not in ['mon', 'mgr', 'osd']: # (mon and mgr specs should always exist; osds aren't matched # to a service spec) + force_delete_data = False + if dd.service_name() in self.mgr.spec_store.spec_deleted: + _, force_delete_data = self.mgr.spec_store.spec_deleted[dd.service_name()] self.log.info('Removing orphan daemon %s...' % dd.name()) - self._remove_daemon(dd.name(), dd.hostname) - - # ignore unmanaged services - if spec and spec.unmanaged: + self._remove_daemon(dd.name(), dd.hostname, force_delete_data=force_delete_data) + # This daemon was removed from cache; skip any additional checks + # in this iteration to avoid looking up stale daemon entries. continue - # ignore daemons for deleted services - if dd.service_name() in self.mgr.spec_store.spec_deleted: + if spec and spec.unmanaged: continue if dd.daemon_type == 'agent': @@ -1593,7 +1594,7 @@ class CephadmServe: ic_params.append(ic.to_json(flatten_args=True)) return ic_meta - def _remove_daemon(self, name: str, host: str, no_post_remove: bool = False) -> str: + def _remove_daemon(self, name: str, host: str, no_post_remove: bool = False, force_delete_data: bool = False) -> str: """ Remove a daemon """ @@ -1612,10 +1613,11 @@ class CephadmServe: service_registry.get_service(daemon_type_to_service(daemon_type)).pre_remove(daemon) # NOTE: we are passing the 'force' flag here, which means # we can delete a mon instances data. + args = ['--name', name, '--force'] + if force_delete_data: + args.append('--force-delete-data') if dd.ports: - args = ['--name', name, '--force', '--tcp-ports', ' '.join(map(str, dd.ports))] - else: - args = ['--name', name, '--force'] + args.extend(['--tcp-ports', ' '.join(map(str, dd.ports))]) self.log.info('Removing daemon %s from %s -- ports %s' % (name, host, dd.ports)) with self.mgr.async_timeout_handler(host, f'cephadm rm-daemon (daemon {name})'): diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index 264919cd0f9..2eb16c85cb9 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -1597,6 +1597,50 @@ class TestCephadm(object): out = wait(cephadm_module, c) assert out == ["Removed rgw.myrgw.myhost.myid from host 'test'"] + @pytest.mark.parametrize( + "daemon_name", + ["osd.424242", "prometheus.force_delete_data_regression"] + ) + @mock.patch("cephadm.serve.CephadmServe._run_cephadm") + def test_remove_daemon_force_delete_data_passes_cephadm_flag(self, _run_cephadm, cephadm_module, daemon_name): + """Regression: force_delete_data must be passed as a keyword to _remove_daemon. + + Otherwise the third positional arg is bound to no_post_remove and cephadm + never receives --force-delete-data (see _remove_daemon parameter order). + """ + ls_json = json.dumps([ + dict( + name=daemon_name, + style='cephadm', + fsid='fsid', + container_id='container_id', + version='version', + state='running', + ) + ]) + + async def side_effect(host, entity, command, args, **kwargs): + if command == 'ls': + return [ls_json], '', 0 + return ['{}'], '', 0 + + _run_cephadm.side_effect = side_effect + + with with_host(cephadm_module, 'test'): + CephadmServe(cephadm_module)._refresh_host_daemons('test') + c = cephadm_module.list_daemons() + wait(cephadm_module, c) + c = cephadm_module.remove_daemons([daemon_name], force_delete_data=True) + wait(cephadm_module, c) + + rm_daemon_calls = [ + call for call in _run_cephadm.call_args_list + if len(call[0]) >= 4 and call[0][2] == 'rm-daemon' + ] + assert len(rm_daemon_calls) == 1 + rm_args = rm_daemon_calls[0][0][3] + assert '--force-delete-data' in rm_args + @mock.patch("cephadm.serve.CephadmServe._run_cephadm") def test_remove_duplicate_osds(self, _run_cephadm, cephadm_module: CephadmOrchestrator): _run_cephadm.side_effect = async_side_effect(('{}', '', 0)) diff --git a/src/pybind/mgr/orchestrator/_interface.py b/src/pybind/mgr/orchestrator/_interface.py index 136fde595ac..a230594d60f 100644 --- a/src/pybind/mgr/orchestrator/_interface.py +++ b/src/pybind/mgr/orchestrator/_interface.py @@ -662,7 +662,7 @@ class Orchestrator(object): """ raise NotImplementedError() - def remove_daemons(self, names: List[str]) -> OrchResult[List[str]]: + def remove_daemons(self, names: List[str], force_delete_data: bool = False) -> OrchResult[List[str]]: """ Remove specific daemon(s). @@ -670,7 +670,7 @@ class Orchestrator(object): """ raise NotImplementedError() - def remove_service(self, service_name: str, force: bool = False) -> OrchResult[str]: + def remove_service(self, service_name: str, force: bool = False, force_delete_data: bool = False) -> OrchResult[str]: """ Remove a service (a collection of daemons). diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index 4b4489237e8..7b559370c33 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -1802,25 +1802,67 @@ Usage: @OrchestratorCLICommand.Write('orch daemon rm') def _daemon_rm(self, names: List[str], - force: Optional[bool] = False) -> HandleCommandResult: - """Remove specific daemon(s)""" + force: bool = False, + force_delete_data: bool = False) -> HandleCommandResult: + """ + Remove specific daemon(s). + + When used with --force-delete-data, data for certain daemon types + (mon, osd, prometheus) will be deleted instead of being moved to + /removed/. + """ for name in names: if '.' not in name: - return HandleCommandResult(stderr=f"{name} is not a valid daemon name", retval=-errno.EINVAL) - (daemon_type) = name.split('.')[0] + return HandleCommandResult( + stderr=f"{name} is not a valid daemon name", + retval=-errno.EINVAL + ) + + daemon_type = name.split('.')[0] + if not force and daemon_type in ['osd', 'mon', 'prometheus']: - return HandleCommandResult(stderr=f"must pass --force to REMOVE daemon with potentially PRECIOUS DATA for {name}", retval=-errno.EPERM) - completion = self.remove_daemons(names) + return HandleCommandResult( + stderr=f"must pass --force to remove daemon with potentially precious data for {name}", + retval=-errno.EPERM + ) + + if force_delete_data and not force: + # extra safety: don’t allow delete-data without force + return HandleCommandResult( + stderr="--force-delete-data requires --force", + retval=-errno.EPERM + ) + + completion = self.remove_daemons( + names, + force_delete_data=force_delete_data, + ) return completion_to_result(completion) @OrchestratorCLICommand.Write('orch rm') def _service_rm(self, service_name: str, - force: bool = False) -> HandleCommandResult: - """Remove a service""" + force: bool = False, + force_delete_data: bool = False) -> HandleCommandResult: + """ + Remove a service. + + When used with --force-delete-data, data for stateful daemons belonging + to this service (e.g. mon, osd, prometheus) will be deleted instead of + being moved to /removed/. + """ if service_name in ['mon', 'mgr'] and not force: raise OrchestratorError('The mon and mgr services cannot be removed') - completion = self.remove_service(service_name, force=force) + + if force_delete_data and not force: + # same safety rule as for daemon rm + raise OrchestratorError('--force-delete-data requires --force') + + completion = self.remove_service( + service_name, + force=force, + force_delete_data=force_delete_data, + ) raise_if_exception(completion) return HandleCommandResult(stdout=completion.result_str()) From a22a63030fc99285fc2d3cbceeceea9349790fa6 Mon Sep 17 00:00:00 2001 From: "Kamoltat (Junior) Sirivadhna" Date: Tue, 28 Apr 2026 20:13:10 +0000 Subject: [PATCH 034/596] src/script: init test_stretch crush_collisions.sh Add script to test for CRUSH retry exhaustion in stretch mode with 2 datacenters. Tests unbiased stretch rules by running multiple iterations of PG mappings and checking for collisions that exceed the 50-try limit. Also add --show-retry-exhaustion flag to crushtool to detect and report when CRUSH mapping hits the maximum retry limit. Signed-off-by: Kamoltat (Junior) Sirivadhna --- src/crush/CrushTester.cc | 43 ++++- src/crush/CrushTester.h | 9 ++ src/script/test_stretch_crush_collisions.sh | 171 ++++++++++++++++++++ src/tools/crushtool.cc | 5 + 4 files changed, 220 insertions(+), 8 deletions(-) create mode 100755 src/script/test_stretch_crush_collisions.sh diff --git a/src/crush/CrushTester.cc b/src/crush/CrushTester.cc index 9fc95d8df9c..9f5e7d9061c 100644 --- a/src/crush/CrushTester.cc +++ b/src/crush/CrushTester.cc @@ -475,7 +475,7 @@ int CrushTester::test(CephContext* cct) // make adjustments adjust_weights(weight); - if (output_choose_tries) + if (output_choose_tries || show_retry_exhaustion) crush.start_choose_profile(); for (int r = min_rule; r < crush.get_max_rules() && r <= max_rule; r++) { @@ -673,15 +673,42 @@ int CrushTester::test(CephContext* cct) } } - if (output_choose_tries) { + if (output_choose_tries || show_retry_exhaustion) { __u32 *v = 0; int n = crush.get_choose_profile(&v); - for (int i=0; i 0 && v[n-1] > 0) { + cerr << std::endl; + cerr << "WARNING: Retry exhaustion detected!" << std::endl; + cerr << " " << v[n-1] << " PG(s) hit the maximum retry limit of " << (n) << std::endl; + cerr << " This indicates CRUSH failed to find optimal placement for some PGs." << std::endl; + cerr << std::endl; + } else { + cout << std::endl; + cout << "No retry exhaustion detected (maximum tries needed: "; + // Find the actual maximum tries used + int max_tries_used = 1; + for (int i = n-1; i >= 0; i--) { + if (v[i] > 0) { + max_tries_used = i+1; + break; + } + } + cout << max_tries_used << " / " << (n) << ")" << std::endl; + cout << std::endl; + } } crush.stop_choose_profile(); diff --git a/src/crush/CrushTester.h b/src/crush/CrushTester.h index 49e41de3080..daacc26ad97 100644 --- a/src/crush/CrushTester.h +++ b/src/crush/CrushTester.h @@ -31,6 +31,7 @@ class CrushTester { bool output_mappings; bool output_bad_mappings; bool output_choose_tries; + bool show_retry_exhaustion; bool output_data_file; bool output_csv; @@ -182,6 +183,7 @@ public: output_mappings(false), output_bad_mappings(false), output_choose_tries(false), + show_retry_exhaustion(false), output_data_file(false), output_csv(false), output_data_file_name("") @@ -251,6 +253,13 @@ public: return output_choose_tries; } + void set_show_retry_exhaustion(bool b) { + show_retry_exhaustion = b; + } + bool get_show_retry_exhaustion() const { + return show_retry_exhaustion; + } + void set_batches(int b) { num_batches = b; } diff --git a/src/script/test_stretch_crush_collisions.sh b/src/script/test_stretch_crush_collisions.sh new file mode 100755 index 00000000000..dc924f1b577 --- /dev/null +++ b/src/script/test_stretch_crush_collisions.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# Test script to detect CRUSH retry exhaustion in stretch mode configurations +# Tests whether unbiased stretch rules with exactly 2 datacenters experience +# collision retry exhaustion (hitting the 50-try limit) + +set -e + +# Find the script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Default to build directory at repo root, but allow override +BUILD_DIR="${BUILD_DIR:-$REPO_ROOT/build}" + +# Find crushtool +if [ -n "$CRUSHTOOL" ]; then + # User specified CRUSHTOOL, use it + : +elif [ -x "$BUILD_DIR/bin/crushtool" ]; then + CRUSHTOOL="$BUILD_DIR/bin/crushtool" +else + echo "Error: Cannot find crushtool. Please set BUILD_DIR or CRUSHTOOL environment variable." + exit 1 +fi + +OUTPUT_DIR="${OUTPUT_DIR:-./crush_test_results}" +NUM_PGS="${NUM_PGS:-100000}" + +mkdir -p "$OUTPUT_DIR" + +# Function to create a CRUSH map with 2 datacenters +create_crush_map() { + local filename=$1 + + cat > "$filename" <<'EOF' +# CRUSH map for stretch mode with 2 datacenters +# Matches real cluster structure + +# devices +device 0 osd.0 +device 1 osd.1 +device 2 osd.2 +device 3 osd.3 +device 4 osd.4 +device 5 osd.5 +device 6 osd.6 +device 7 osd.7 + +# types +type 0 osd +type 1 host +type 2 datacenter +type 3 root + +# buckets +host host1 { + id -9 + alg straw2 + hash 0 + item osd.0 weight 1.0 + item osd.1 weight 1.0 +} + +host host2 { + id -10 + alg straw2 + hash 0 + item osd.2 weight 1.0 + item osd.3 weight 1.0 +} + +host host3 { + id -11 + alg straw2 + hash 0 + item osd.4 weight 1.0 + item osd.5 weight 1.0 +} + +host host4 { + id -12 + alg straw2 + hash 0 + item osd.6 weight 1.0 + item osd.7 weight 1.0 +} + +datacenter dc1 { + id -5 + alg straw2 + hash 0 + item host1 weight 2.0 + item host2 weight 2.0 +} + +datacenter dc2 { + id -7 + alg straw2 + hash 0 + item host3 weight 2.0 + item host4 weight 2.0 +} + +root default { + id -1 + alg straw2 + hash 0 + item dc1 weight 4.0 + item dc2 weight 4.0 +} + +# CRUSH rules +rule stretch_replicated_rule { + id 0 + type replicated + step take default + step choose firstn 0 type datacenter + step chooseleaf firstn 2 type host + step emit +} +EOF +} + +# Function to test a CRUSH map +test_crush_map() { + local map_txt=$1 + local map_bin=$2 + local rule_id=$3 + local rule_name=$4 + local iteration=$5 + + if $CRUSHTOOL -i "$map_bin" --test \ + --min-x 1 \ + --max-x "$NUM_PGS" \ + --rule "$rule_id" \ + --num-rep 4 \ + --show-statistics \ + --set-choose-total-tries 50 \ + --show-retry-exhaustion 2>&1 | grep -q "WARNING: Retry exhaustion detected!"; then + return 1 # Retry exhaustion detected - failure + else + return 0 # No retry exhaustion - success + fi +} + +echo "Testing Unbiased Rule with 2 Datacenters" +echo "Running 100 iterations with $NUM_PGS PGs each..." +echo "" + +create_crush_map "$OUTPUT_DIR/crush_2dc.txt" + +echo "Compiling CRUSH map..." +$CRUSHTOOL -c "$OUTPUT_DIR/crush_2dc.txt" -o "$OUTPUT_DIR/crush_2dc.bin" + +for i in $(seq 1 100); do + echo -n " Iteration $i/100: " + + if test_crush_map \ + "$OUTPUT_DIR/crush_2dc.txt" \ + "$OUTPUT_DIR/crush_2dc.bin" \ + 0 \ + "stretch_replicated_rule" \ + "$i"; then + echo "OK" + else + echo "RETRY EXHAUSTION DETECTED" + exit 1 + fi +done + + diff --git a/src/tools/crushtool.cc b/src/tools/crushtool.cc index 21b3efd3475..17e6e31f9dd 100644 --- a/src/tools/crushtool.cc +++ b/src/tools/crushtool.cc @@ -229,6 +229,8 @@ void usage() cout << " --show-mappings show mappings\n"; cout << " --show-bad-mappings show bad mappings\n"; cout << " --show-choose-tries show choose tries histogram\n"; + cout << " --show-retry-exhaustion\n"; + cout << " check for and report CRUSH retry exhaustion\n"; cout << " --output-name name\n"; cout << " prepend the data file(s) generated during the\n"; cout << " testing routine with name\n"; @@ -542,6 +544,9 @@ int main(int argc, const char **argv) } else if (ceph_argparse_flag(args, i, "--show_choose_tries", (char*)NULL)) { display = true; tester.set_output_choose_tries(true); + } else if (ceph_argparse_flag(args, i, "--show-retry-exhaustion", (char*)NULL)) { + display = true; + tester.set_show_retry_exhaustion(true); } else if (ceph_argparse_witharg(args, i, &val, "-c", "--compile", (char*)NULL)) { srcfn = val; compile = true; From 8e9e1fa46d1b81cf52f35ad4e83fd635834a324a Mon Sep 17 00:00:00 2001 From: "Kamoltat (Junior) Sirivadhna" Date: Tue, 28 Apr 2026 20:16:00 +0000 Subject: [PATCH 035/596] doc: update crushtool.rst Add --show-retry-exhaustion flag to doc Signed-off-by: Kamoltat (Junior) Sirivadhna --- doc/man/8/crushtool.rst | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/doc/man/8/crushtool.rst b/doc/man/8/crushtool.rst index 4c848659655..f1de7ef37c4 100644 --- a/doc/man/8/crushtool.rst +++ b/doc/man/8/crushtool.rst @@ -151,15 +151,35 @@ pools; it only runs simulations by mapping values in the range Displays how many attempts were needed to find a device mapping. For instance:: - 0: 95224 - 1: 3745 - 2: 2225 + tries 0: 95224 + tries 1: 3745 + tries 2: 2225 .. shows that **95224** mappings succeeded without retries, **3745** mappings succeeded with one attempts, etc. There are as many rows as the value of the **--set-choose-total-tries** option. +.. option:: --show-retry-exhaustion + + Checks whether any PG mappings hit the maximum retry limit + (typically 50 tries) and displays a warning if retry exhaustion + is detected. This is useful for validating CRUSH rules, especially + in stretch cluster configurations with exactly + 2 datacenters and 4 replicas can potentially fail to map some PGs due to CRUSH's + pseudorandom selection repeatedly choosing the same datacenter. + Although, the probability is extremely low, we still want to detect this. + + Outputs either a warning message if exhaustion is detected:: + + WARNING: Retry exhaustion detected! + 5 PG(s) hit the maximum retry limit of 50 + This indicates CRUSH failed to find optimal placement for some PGs. + + or a success message showing the maximum tries actually needed:: + + No retry exhaustion detected (maximum tries needed: 7 / 50) + .. option:: --output-csv Creates CSV files (in the current directory) containing information From 2eb274eef256ba93dd97b7c74b94fb78d761e106 Mon Sep 17 00:00:00 2001 From: Shilpa Jagannath Date: Wed, 29 Apr 2026 18:02:05 +0000 Subject: [PATCH 036/596] rgw/archive: record the bucket creation time at archive zone source bucket deletion triggers a new bucket instance creation at archive zone. record its mtime and display in bucket stats. store it as an attr RGW_ATTR_ARCHIVE_INSTANCE_MTIME Signed-off-by: Shilpa Jagannath --- src/rgw/driver/rados/rgw_bucket.cc | 22 +++++++++++++++++++++- src/rgw/rgw_common.h | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rgw/driver/rados/rgw_bucket.cc b/src/rgw/driver/rados/rgw_bucket.cc index 233163ce78b..0074f15e24f 100644 --- a/src/rgw/driver/rados/rgw_bucket.cc +++ b/src/rgw/driver/rados/rgw_bucket.cc @@ -1641,6 +1641,21 @@ static int bucket_stats(rgw::sal::Driver* driver, const rgw::SiteConfig& site, } ut.gmtime(formatter->dump_stream("mtime")); ctime_ut.gmtime(formatter->dump_stream("creation_time")); + + // if this is an archive zone "-deleted-" bucket, expose when this instance was created + auto archive_mtime_iter = bucket->get_attrs().find(RGW_ATTR_ARCHIVE_INSTANCE_MTIME); + if (archive_mtime_iter != bucket->get_attrs().end()) { + try { + ceph::real_time archive_instance_mtime; + auto bliter = archive_mtime_iter->second.cbegin(); + decode(archive_instance_mtime, bliter); + utime_t archive_mtime_ut(archive_instance_mtime); + archive_mtime_ut.gmtime(formatter->dump_stream("archive_instance_mtime")); + } catch (buffer::error& err) { + ldpp_dout(dpp, 0) << "WARNING: failed to decode archive_instance_mtime attr" << dendl; + } + } + encode_json("bucket_quota", bucket_info.quota, formatter); // bucket tags @@ -2642,7 +2657,7 @@ static void get_md5_digest(const RGWBucketEntryPoint *be, string& md5_digest) { md5_digest = md5; } -#define ARCHIVE_META_ATTR RGW_ATTR_PREFIX "zone.archive.info" +#define ARCHIVE_META_ATTR RGW_ATTR_PREFIX "zone.archive.info" struct archive_meta_info { rgw_bucket orig_bucket; @@ -2757,6 +2772,11 @@ class RGWArchiveBucketMetadataHandler : public RGWBucketMetadataHandler { new_be.bucket.name = new_bucket_name; + ceph::real_time instance_mtime = ceph::real_clock::now(); + bufferlist bl; + encode(instance_mtime, bl); + attrs_m[RGW_ATTR_ARCHIVE_INSTANCE_MTIME] = std::move(bl); + ret = ctl_bucket->store_bucket_instance_info(new_be.bucket, new_bi, y, dpp, RGWBucketCtl::BucketInstance::PutParams() .set_exclusive(false) .set_mtime(orig_mtime) diff --git a/src/rgw/rgw_common.h b/src/rgw/rgw_common.h index 8f95f4ef671..38d7520ae39 100644 --- a/src/rgw/rgw_common.h +++ b/src/rgw/rgw_common.h @@ -115,6 +115,7 @@ using ceph::crypto::MD5; #define RGW_ATTR_BUCKET_LOGGING RGW_ATTR_PREFIX "logging" #define RGW_ATTR_BUCKET_LOGGING_MTIME RGW_ATTR_PREFIX "logging-mtime" #define RGW_ATTR_BUCKET_LOGGING_SOURCES RGW_ATTR_PREFIX "logging-sources" +#define RGW_ATTR_ARCHIVE_INSTANCE_MTIME RGW_ATTR_PREFIX "zone.archive.instance.mtime" /* S3 Object Lock*/ #define RGW_ATTR_OBJECT_LOCK RGW_ATTR_PREFIX "object-lock" From 88999681041a14e386417f32ed6686b524a4d534 Mon Sep 17 00:00:00 2001 From: "Kamoltat (Junior) Sirivadhna" Date: Wed, 29 Apr 2026 14:11:41 +0000 Subject: [PATCH 037/596] src/test: update show-choose-tries.t tests Since we modified crushtool cli commands, we need to also update its test with new flag: --show-retry-exhaustion and also the modified --show-choose-tries option Also added /src/script/run-cli-tests.sh to run the cram test easily without having the config headache Signed-off-by: Kamoltat (Junior) Sirivadhna --- doc/dev/developer_guide/tests-unit-tests.rst | 25 +++ src/script/run-cli-tests.sh | 67 +++++++ src/test/cli/crushtool/help.t | 2 + src/test/cli/crushtool/show-choose-tries.t | 200 +++++++++---------- 4 files changed, 194 insertions(+), 100 deletions(-) create mode 100755 src/script/run-cli-tests.sh diff --git a/doc/dev/developer_guide/tests-unit-tests.rst b/doc/dev/developer_guide/tests-unit-tests.rst index e6026dd3c63..0ee13779234 100644 --- a/doc/dev/developer_guide/tests-unit-tests.rst +++ b/doc/dev/developer_guide/tests-unit-tests.rst @@ -71,6 +71,31 @@ teuthology using the `cram task`_. .. _`cram`: https://bitheap.org/cram/ .. _`cram task`: https://github.com/ceph/ceph/blob/master/qa/tasks/cram.py +Running CLI tools tests +----------------------- + +A convenience script is provided to run CLI tests easily without having to manually set up the environment. +From the Ceph source tree root directory: + + .. prompt:: bash $ + + # Run all CLI tests + ./src/script/run-cli-tests.sh + + # Run with custom build directory + ./src/script/run-cli-tests.sh -b /path/to/build + +Alternatively, you can run the tests directly from the build directory by setting up your PATH: + + .. prompt:: bash $ + + cd build + PATH="$PWD/bin:$PATH" ../src/test/run-cli-tests + +The test suite includes tests for: ``ceph-authtool``, ``ceph-conf``, +``ceph-kvstore-tool``, ``crushtool``, ``monmaptool``, ``osdmaptool``, +``radosgw-admin``, and ``rbd``. + Tox-based testing of Python modules ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Some of the Python modules in Ceph use `tox `_ diff --git a/src/script/run-cli-tests.sh b/src/script/run-cli-tests.sh new file mode 100755 index 00000000000..1e6c8522e61 --- /dev/null +++ b/src/script/run-cli-tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CEPH_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUILD_DIR="${BUILD_DIR:-$CEPH_ROOT/build}" + +usage() { + cat < Date: Wed, 21 Jan 2026 21:13:39 +0530 Subject: [PATCH 038/596] mgr/cephadm: Updated NFS default protocol to v4 and v3 is enabled only when enable_nfsv3 is set in the spec Fixes: https://tracker.ceph.com/issues/74492 Signed-off-by: Shweta Bhosale --- doc/cephadm/services/nfs.rst | 3 +++ doc/mgr/nfs.rst | 5 +++- src/pybind/mgr/cephadm/services/nfs.py | 3 ++- .../templates/services/nfs/ganesha.conf.j2 | 4 +-- .../cephadm/tests/services/test_ingress.py | 2 +- .../mgr/cephadm/tests/services/test_nfs.py | 26 +++++++++++++++++++ src/pybind/mgr/nfs/cluster.py | 5 ++++ src/pybind/mgr/nfs/module.py | 2 ++ .../ceph/deployment/service_spec.py | 2 ++ 9 files changed, 47 insertions(+), 5 deletions(-) diff --git a/doc/cephadm/services/nfs.rst b/doc/cephadm/services/nfs.rst index 07d19df950d..ecb31bff93f 100644 --- a/doc/cephadm/services/nfs.rst +++ b/doc/cephadm/services/nfs.rst @@ -60,6 +60,7 @@ Alternatively, an NFS service can be applied using a YAML specification. host2: 10.0.0.124 monitoring_networks: - 192.168.124.0/24 + enable_nfsv3: true In this example, we run the server on the non-default ``port`` of @@ -78,6 +79,8 @@ IP address is assigned to the host, that IP address will be used. If the IP address is not present and ``monitoring_networks`` is specified, an IP address that matches one of the specified networks will be used. If neither condition is met, the default binding will happen on all available network interfaces. +By default, only the NFSv4 protocol is enabled. NFSv3 can be enabled by setting +``enable_nfsv3`` to ``true`` in the service specification. NFS over RDMA ------------- diff --git a/doc/mgr/nfs.rst b/doc/mgr/nfs.rst index e09378b26cd..4ef7a9581b9 100644 --- a/doc/mgr/nfs.rst +++ b/doc/mgr/nfs.rst @@ -31,7 +31,7 @@ Create NFS Ganesha Cluster .. prompt:: bash # - ceph nfs cluster create [] [--ingress] [--virtual_ip ] [--ingress-mode {default|keepalive-only|haproxy-standard|haproxy-protocol}] [--port ] [--enable-rdma] [--rdma_port ] [-i ] + ceph nfs cluster create [] [--ingress] [--virtual_ip ] [--ingress-mode {default|keepalive-only|haproxy-standard|haproxy-protocol}] [--port ] [--enable-rdma] [--rdma_port ] [-i ] [--enable-nfsv3] This creates a common recovery pool for all NFS Ganesha daemons, new user based on ``cluster_id``, and a common NFS Ganesha config RADOS object. @@ -62,6 +62,9 @@ cluster):: NFS can be deployed on a port other than 2049 (the default) with ``--port ``. +By default, only NFS v4 protocol is enabled. To enable both NFS v3 and v4 protocols, +add the ``--enable-nfsv3`` flag. + To deploy NFS with a high-availability front-end (virtual IP and load balancer), add the ``--ingress`` flag and specify a virtual IP address. This will deploy a combination of keepalived and haproxy to provide an high-availability NFS frontend for the NFS diff --git a/src/pybind/mgr/cephadm/services/nfs.py b/src/pybind/mgr/cephadm/services/nfs.py index ac01900eb02..1c26f8a0644 100644 --- a/src/pybind/mgr/cephadm/services/nfs.py +++ b/src/pybind/mgr/cephadm/services/nfs.py @@ -261,7 +261,8 @@ class NFSService(CephService): "tls_min_version": spec.tls_min_version, "tls_ktls": spec.tls_ktls, "tls_debug": spec.tls_debug, - "ceph_nodes": ceph_nodes + "ceph_nodes": ceph_nodes, + "protocols": "3, 4" if spec.enable_nfsv3 else "4" } if spec.enable_haproxy_protocol: context["haproxy_hosts"] = self._haproxy_hosts() diff --git a/src/pybind/mgr/cephadm/templates/services/nfs/ganesha.conf.j2 b/src/pybind/mgr/cephadm/templates/services/nfs/ganesha.conf.j2 index 606b1c5704f..2d211f4e7b0 100644 --- a/src/pybind/mgr/cephadm/templates/services/nfs/ganesha.conf.j2 +++ b/src/pybind/mgr/cephadm/templates/services/nfs/ganesha.conf.j2 @@ -3,9 +3,9 @@ NFS_CORE_PARAM { Enable_NLM = {{ enable_nlm }}; Enable_RQUOTA = false; {% if enable_rdma %} - Protocols = 3, 4, nfsrdma, rpcrdma; + Protocols = {{ protocols }}, nfsrdma, rpcrdma; {% else %} - Protocols = 3, 4; + Protocols = {{ protocols }}; {% endif %} mount_path_pseudo = true; Enable_UDP = false; diff --git a/src/pybind/mgr/cephadm/tests/services/test_ingress.py b/src/pybind/mgr/cephadm/tests/services/test_ingress.py index 86692935e18..a5bbe078d6e 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_ingress.py +++ b/src/pybind/mgr/cephadm/tests/services/test_ingress.py @@ -1188,7 +1188,7 @@ class TestIngressService: 'NFS_CORE_PARAM {\n' ' Enable_NLM = true;\n' ' Enable_RQUOTA = false;\n' - ' Protocols = 3, 4;\n' + ' Protocols = 4;\n' ' mount_path_pseudo = true;\n' ' Enable_UDP = false;\n' ' NFS_Port = 2049;\n' diff --git a/src/pybind/mgr/cephadm/tests/services/test_nfs.py b/src/pybind/mgr/cephadm/tests/services/test_nfs.py index 376fdea25e3..115832e4635 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_nfs.py +++ b/src/pybind/mgr/cephadm/tests/services/test_nfs.py @@ -588,6 +588,32 @@ class TestNFS: assert "nfsrdma" not in ganesha_conf assert "NFS_RDMA_Port" not in ganesha_conf + @patch("cephadm.serve.CephadmServe._run_cephadm") + @patch("cephadm.services.nfs.NFSService.fence_old_ranks", MagicMock()) + @patch("cephadm.services.nfs.NFSService.run_grace_tool", MagicMock()) + @patch("cephadm.services.nfs.NFSService.purge", MagicMock()) + @patch("cephadm.services.nfs.NFSService.create_rados_config_obj", MagicMock()) + def test_nfs_enable_nfsv3(self, _run_cephadm, cephadm_module: CephadmOrchestrator): + _run_cephadm.side_effect = async_side_effect(('{}', '', 0)) + + with with_host(cephadm_module, 'test'): + # Test with enable_nfsv3=False (default) + nfs_spec = NFSServiceSpec(service_id="foo", placement=PlacementSpec(hosts=['test'])) + with with_service(cephadm_module, nfs_spec) as _: + nfs_generated_conf, _ = service_registry.get_service('nfs').generate_config( + CephadmDaemonDeploySpec(host='test', daemon_id='foo.test.0.0', service_name=nfs_spec.service_name())) + ganesha_conf = nfs_generated_conf['files']['ganesha.conf'] + assert "Protocols = 4;" in ganesha_conf + + # Test with enable_nfsv3=True + nfs_spec = NFSServiceSpec(service_id="foo", placement=PlacementSpec(hosts=['test']), + enable_nfsv3=True) + with with_service(cephadm_module, nfs_spec) as _: + nfs_generated_conf, _ = service_registry.get_service('nfs').generate_config( + CephadmDaemonDeploySpec(host='test', daemon_id='foo.test.0.0', service_name=nfs_spec.service_name())) + ganesha_conf = nfs_generated_conf['files']['ganesha.conf'] + assert "Protocols = 3, 4;" in ganesha_conf + def test_nfs_colocation_ports_validation(): """Test validation of colocation_ports in NFSServiceSpec""" diff --git a/src/pybind/mgr/nfs/cluster.py b/src/pybind/mgr/nfs/cluster.py index 5a4cf057aa2..1beff39ff93 100644 --- a/src/pybind/mgr/nfs/cluster.py +++ b/src/pybind/mgr/nfs/cluster.py @@ -161,6 +161,7 @@ class NFSCluster: ingress_mode: Optional[IngressType] = None, port: Optional[int] = None, cluster_qos_config: Optional[Dict[str, Union[str, bool, int]]] = None, + enable_nfsv3: bool = False, ssl: bool = False, ssl_cert: Optional[str] = None, ssl_key: Optional[str] = None, @@ -206,6 +207,7 @@ class NFSCluster: virtual_ip=virtual_ip_for_ganesha, enable_haproxy_protocol=enable_haproxy_protocol, cluster_qos_config=cluster_qos_config, + enable_nfsv3=enable_nfsv3, ssl=ssl, ssl_cert=ssl_cert, ssl_key=ssl_key, @@ -235,6 +237,7 @@ class NFSCluster: placement=PlacementSpec.from_string(placement), port=port, cluster_qos_config=cluster_qos_config, + enable_nfsv3=enable_nfsv3, ssl=ssl, ssl_cert=ssl_cert, ssl_key=ssl_key, @@ -269,6 +272,7 @@ class NFSCluster: ingress_mode: Optional[IngressType] = None, port: Optional[int] = None, cluster_qos_config: Optional[Dict[str, Union[str, bool, int]]] = None, + enable_nfsv3: bool = False, ssl: bool = False, ssl_cert: Optional[str] = None, ssl_key: Optional[str] = None, @@ -309,6 +313,7 @@ class NFSCluster: ingress_mode, port, cluster_qos_config=cluster_qos_config, + enable_nfsv3=enable_nfsv3, ssl=ssl, ssl_cert=ssl_cert, ssl_key=ssl_key, diff --git a/src/pybind/mgr/nfs/module.py b/src/pybind/mgr/nfs/module.py index ab6369e346e..7cd2aa70dcc 100644 --- a/src/pybind/mgr/nfs/module.py +++ b/src/pybind/mgr/nfs/module.py @@ -162,6 +162,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): port: Optional[int] = None, enable_rdma: bool = False, rdma_port: Optional[int] = None, + enable_nfsv3: bool = False, inbuf: Optional[str] = None) -> None: """Create an NFS Cluster""" cluster_qos_config = None @@ -183,6 +184,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): virtual_ip=virtual_ip, ingress=ingress, ingress_mode=ingress_mode, port=port, cluster_qos_config=cluster_qos_config, + enable_nfsv3=enable_nfsv3, ssl=ssl, ssl_cert=ssl_cert, ssl_key=ssl_key, diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 9ac9db45871..c0b9ff873d5 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -1393,6 +1393,7 @@ class NFSServiceSpec(ServiceSpec): tls_min_version: Optional[str] = None, tls_ciphers: Optional[str] = None, colocation_ports: Optional[List[Dict[str, int]]] = None, + enable_nfsv3: bool = False, ): assert service_type == 'nfs' super(NFSServiceSpec, self).__init__( @@ -1421,6 +1422,7 @@ class NFSServiceSpec(ServiceSpec): self.rdma_port = rdma_port self.cluster_qos_config = cluster_qos_config self.cluster_qos_port = cluster_qos_port + self.enable_nfsv3 = enable_nfsv3 # colocation_ports is a list of port dicts for ADDITIONAL colocated daemons # The first daemon always uses port and monitoring_port from the spec From 44c13620288c434aef6fc00aa4e7a8c64c7e4134 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 6 Feb 2026 18:27:58 +0530 Subject: [PATCH 039/596] mgr/nfs: set nfs export protocols based on cluster's protocol settings Fixes: https://tracker.ceph.com/issues/74492 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/nfs/export.py | 30 ++++++++++++++++++++++++++++ src/pybind/mgr/nfs/ganesha_conf.py | 2 +- src/pybind/mgr/nfs/tests/test_nfs.py | 14 ++++++------- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/pybind/mgr/nfs/export.py b/src/pybind/mgr/nfs/export.py index 9194f380eb8..304d7c3511e 100644 --- a/src/pybind/mgr/nfs/export.py +++ b/src/pybind/mgr/nfs/export.py @@ -162,6 +162,25 @@ class ExportMgr: self._exports: Optional[Dict[str, List[Export]]] = export_ls self.skip_notify_nfs_server = False + def _get_cluster_protocols(self, cluster_id: str) -> List[int]: + """Get the list of supported NFS protocols for a cluster. + """ + try: + import orchestrator + from ceph.deployment.service_spec import NFSServiceSpec + + completion = self.mgr.describe_service(service_type='nfs', service_name=f'nfs.{cluster_id}') + services = orchestrator.raise_if_exception(completion) + for service in services: + if service.spec and isinstance(service.spec, NFSServiceSpec): + spec = cast(NFSServiceSpec, service.spec) + if getattr(spec, 'enable_nfsv3', False): + return [3, 4] + return [4] + except Exception as e: + log.debug(f"Failed to get cluster protocols for {cluster_id}: {e}, defaulting to v4 only") + return [4] + @property def exports(self) -> Dict[str, List[Export]]: if self._exports is None: @@ -696,6 +715,8 @@ class ExportMgr: _validate_cmount_path(cmount_path, path) # type: ignore pseudo_path = normalize_path(pseudo_path) + # Get the protocols based on cluster's enable_nfsv3 setting + protocols = self._get_cluster_protocols(cluster_id) export_dict = { "pseudo": pseudo_path, @@ -710,6 +731,7 @@ class ExportMgr: "clients": clients, "sectype": sectype, "XprtSec": xprtsec, + "protocols": protocols, } if transports is not None: export_dict["transports"] = transports @@ -750,6 +772,9 @@ class ExportMgr: if not bucket and not user_id: raise ErrorResponse("Must specify either bucket or user_id") + # Get the protocols based on cluster's enable_nfsv3 setting + protocols = self._get_cluster_protocols(cluster_id) + export_dict = { "pseudo": pseudo_path, "path": bucket or '/', @@ -762,6 +787,7 @@ class ExportMgr: "clients": clients, "sectype": sectype, "XprtSec": xprtsec, + "protocols": protocols, } if transports is not None: export_dict["transports"] = transports @@ -825,6 +851,10 @@ class ExportMgr: else: new_export_dict['fsal']['cmount_path'] = '/' + # Set protocols based on cluster's enable_nfsv3 setting if not explicitly specified + if 'protocols' not in new_export_dict: + new_export_dict['protocols'] = self._get_cluster_protocols(cluster_id) + new_export = self.create_export_from_dict( cluster_id, new_export_dict.get('export_id', self._gen_export_id(cluster_id)), diff --git a/src/pybind/mgr/nfs/ganesha_conf.py b/src/pybind/mgr/nfs/ganesha_conf.py index 04f97bcba55..9a5b38fb92a 100644 --- a/src/pybind/mgr/nfs/ganesha_conf.py +++ b/src/pybind/mgr/nfs/ganesha_conf.py @@ -474,7 +474,7 @@ class Export: ex_dict.get('access_type', 'RO'), ex_dict.get('squash', 'no_root_squash'), ex_dict.get('security_label', True), - ex_dict.get('protocols', [3, 4]), + ex_dict.get('protocols', [4]), ex_dict.get('transports', ['TCP']), FSAL.from_dict(ex_dict.get('fsal', {})), [Client.from_dict(client) for client in ex_dict.get('clients', [])], diff --git a/src/pybind/mgr/nfs/tests/test_nfs.py b/src/pybind/mgr/nfs/tests/test_nfs.py index 57db70a2002..3959f44f28a 100644 --- a/src/pybind/mgr/nfs/tests/test_nfs.py +++ b/src/pybind/mgr/nfs/tests/test_nfs.py @@ -126,7 +126,7 @@ EXPORT { Path = /; Pseudo = /cephfs_b/; Access_Type = RW; - Protocols = 4; + Protocols = 3, 4; Attr_Expiration_Time = 0; FSAL { @@ -491,7 +491,7 @@ NFS_CORE_PARAM { assert export.pseudo == "/cephfs_b/" assert export.access_type == "RW" assert export.squash == "no_root_squash" - assert export.protocols == [4] + assert export.protocols == [3, 4] assert export.fsal.name == "CEPH" assert export.fsal.user_id == "nfs.foo.b.lgudhr" assert export.fsal.fs_name == "b" @@ -1122,7 +1122,7 @@ NFS_CORE_PARAM { assert export.pseudo == "/mybucket" assert export.access_type == "none" assert export.squash == "none" - assert export.protocols == [3, 4] + assert export.protocols == [4] assert export.transports == ["TCP"] assert export.fsal.name == "RGW" assert export.fsal.user_id == "bucket_owner_user" @@ -1166,7 +1166,7 @@ NFS_CORE_PARAM { assert export.pseudo == "/mybucket" assert export.access_type == "none" assert export.squash == "none" - assert export.protocols == [3, 4] + assert export.protocols == [4] assert export.transports == ["TCP"] assert export.fsal.name == "RGW" assert export.fsal.access_key_id == "the_access_key" @@ -1208,7 +1208,7 @@ NFS_CORE_PARAM { assert export.pseudo == "/mybucket" assert export.access_type == "none" assert export.squash == "none" - assert export.protocols == [3, 4] + assert export.protocols == [4] assert export.transports == ["TCP"] assert export.fsal.name == "RGW" assert export.fsal.access_key_id == "the_access_key" @@ -1257,7 +1257,7 @@ NFS_CORE_PARAM { assert export.pseudo == "/cephfs2" assert export.access_type == "none" assert export.squash == "none" - assert export.protocols == [3, 4] + assert export.protocols == [4] assert export.transports == ["TCP"] assert export.fsal.name == "CEPH" assert export.fsal.user_id == "nfs.foo.myfs.86ca58ef" @@ -1389,7 +1389,7 @@ EXPORT { assert export.pseudo == "/cephfs3" assert export.access_type == "RW" assert export.squash == "root" - assert export.protocols == [3, 4] + assert export.protocols == [4] assert export.fsal.name == "CEPH" assert export.fsal.user_id == "nfs.foo.myfs.86ca58ef" assert export.fsal.cephx_key == "thekeyforclientabc" From 64732c127dba1974cb60bc448ed01eceb0831d0a Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 13 Apr 2026 17:16:22 +0530 Subject: [PATCH 040/596] mgr/nfs: Updated enable_nfsv3 check while export creation Fixes: https://tracker.ceph.com/issues/74492 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/nfs/export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/nfs/export.py b/src/pybind/mgr/nfs/export.py index 304d7c3511e..90278e3d9ba 100644 --- a/src/pybind/mgr/nfs/export.py +++ b/src/pybind/mgr/nfs/export.py @@ -172,7 +172,7 @@ class ExportMgr: completion = self.mgr.describe_service(service_type='nfs', service_name=f'nfs.{cluster_id}') services = orchestrator.raise_if_exception(completion) for service in services: - if service.spec and isinstance(service.spec, NFSServiceSpec): + if service.spec: spec = cast(NFSServiceSpec, service.spec) if getattr(spec, 'enable_nfsv3', False): return [3, 4] From f4a70f197fc14a667e0b8ea1694a5e893e48de30 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 30 Apr 2026 19:16:16 +0530 Subject: [PATCH 041/596] mgr/cephadm: Fixed unittest for NFS protocol Fixes: https://tracker.ceph.com/issues/74492 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/tests/services/test_nfs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/cephadm/tests/services/test_nfs.py b/src/pybind/mgr/cephadm/tests/services/test_nfs.py index 115832e4635..aa0c4d36720 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_nfs.py +++ b/src/pybind/mgr/cephadm/tests/services/test_nfs.py @@ -521,7 +521,7 @@ class TestNFS: ports=[2049, 9587, 20049], )) ganesha_conf = nfs_generated_conf['files']['ganesha.conf'] - assert "Protocols = 3, 4, nfsrdma, rpcrdma" in ganesha_conf + assert "Protocols = 4, nfsrdma, rpcrdma" in ganesha_conf @patch("cephadm.serve.CephadmServe._run_cephadm_json") @patch("cephadm.serve.CephadmServe._run_cephadm") @@ -549,6 +549,7 @@ class TestNFS: placement=PlacementSpec(hosts=['host1']), enable_rdma=True, rdma_port=1234, + enable_nfsv3=True, ) with with_service(cephadm_module, nfs_spec) as _: nfs_generated_conf, _ = service_registry.get_service('nfs').generate_config( @@ -584,7 +585,7 @@ class TestNFS: service_name=nfs_spec.service_name(), )) ganesha_conf = nfs_generated_conf['files']['ganesha.conf'] - assert "Protocols = 3, 4" in ganesha_conf + assert "Protocols = 4" in ganesha_conf assert "nfsrdma" not in ganesha_conf assert "NFS_RDMA_Port" not in ganesha_conf From 4a64b83c6c5c7978764ad694da464e35b8d3d013 Mon Sep 17 00:00:00 2001 From: Shubha Jain Date: Tue, 6 Jan 2026 20:49:27 +0530 Subject: [PATCH 042/596] mgr/orchestrator: default NFS ingress to haproxy-protocol mode Change default ingress mode from haproxy-standard to haproxy-protocol to preserve client IP addresses for proper IP-level export restrictions in NFS Ganesha. Fixes: https://tracker.ceph.com/issues/74260 Signed-off-by: Shubha Jain --- src/pybind/mgr/orchestrator/module.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index 56d94fe24ab..5b8e9a6458f 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -222,7 +222,9 @@ class IngressType(enum.Enum): def canonicalize(self) -> "IngressType": if self == self.default: - return IngressType(self.haproxy_standard) + # Default to haproxy-protocol to preserve client IP addresses + # for proper IP-level export restrictions in NFS Ganesha + return IngressType(self.haproxy_protocol) return IngressType(self) @@ -2623,3 +2625,4 @@ Usage: completion = self.update_service(service_type.value, service_type.name, image) raise_if_exception(completion) return HandleCommandResult(stdout=completion.result_str()) + From b40ee54935550c77dd28f0a77b9f80881cb64286 Mon Sep 17 00:00:00 2001 From: Nathan Hoad Date: Thu, 23 Apr 2026 14:03:24 -0400 Subject: [PATCH 043/596] rgw: Remove GC deferred entries options and code. This code has been disabled since v16.2.8. Signed-off-by: Nathan Hoad --- src/cls/rgw_gc/cls_rgw_gc.cc | 20 +--- src/common/options/rgw.yaml.in | 21 +--- src/rgw/driver/rados/rgw_gc.cc | 88 +------------- src/rgw/driver/rados/rgw_gc.h | 6 - src/rgw/driver/rados/rgw_rados.cc | 37 ------ src/rgw/driver/rados/rgw_rados.h | 1 - src/test/cls_rgw_gc/test_cls_rgw_gc.cc | 155 ------------------------- 7 files changed, 5 insertions(+), 323 deletions(-) diff --git a/src/cls/rgw_gc/cls_rgw_gc.cc b/src/cls/rgw_gc/cls_rgw_gc.cc index ecaba00cacf..60d6d2dedbb 100644 --- a/src/cls/rgw_gc/cls_rgw_gc.cc +++ b/src/cls/rgw_gc/cls_rgw_gc.cc @@ -47,8 +47,7 @@ static int cls_rgw_gc_queue_init(cls_method_context_t hctx, bufferlist *in, buff CLS_LOG(10, "INFO: cls_rgw_gc_queue_init: queue size is %lu\n", op.size); init_op.queue_size = op.size; - const auto& conf = cls_get_config(hctx); - init_op.max_urgent_data_size = conf->rgw_gc_max_deferred_entries_size; + init_op.max_urgent_data_size = 0; encode(urgent_data, init_op.bl_urgent_data); return queue_init(hctx, init_op); @@ -497,22 +496,6 @@ static int cls_rgw_gc_queue_update_entry(cls_method_context_t hctx, bufferlist * return -ENOSPC; } - // Due to Tracker 47866 we are no longer executing this code, as it - // appears to possibly create a GC entry for an object that has not - // been deleted. Instead we will log at level 0 to perhaps confirm - // that when and how often this bug would otherwise be hit. -#if 0 - cls_queue_enqueue_op enqueue_op; - bufferlist bl_data; - encode(op.info, bl_data); - enqueue_op.bl_data_vec.emplace_back(bl_data); - CLS_LOG(10, "INFO: cls_gc_update_entry: Data size is: %u \n", bl_data.length()); - - ret = queue_enqueue(hctx, enqueue_op, head); - if (ret < 0) { - return ret; - } -#else std::string first_chain = ""; if (! op.info.chain.objs.empty()) { first_chain = op.info.chain.objs.cbegin()->key.name; @@ -521,7 +504,6 @@ static int cls_rgw_gc_queue_update_entry(cls_method_context_t hctx, bufferlist * "INFO: refrained from enqueueing GC entry during GC defer" " tag=%s, first_chain=%s\n", op.info.tag.c_str(), first_chain.c_str()); -#endif if (has_urgent_data) { head.bl_urgent_data.clear(); diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index 57000c5154b..b52d85d758a 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -1962,34 +1962,17 @@ options: - rgw_gc_processor_max_time - rgw_gc_max_concurrent_io with_legacy: true -- name: rgw_gc_max_deferred_entries_size - type: uint - level: advanced - desc: maximum allowed size of deferred entries in queue head for gc - default: 3_K - services: - - rgw - with_legacy: true - name: rgw_gc_max_queue_size type: uint level: advanced desc: Maximum allowed queue size for gc long_desc: The maximum allowed size of each gc queue, and its value should not be - greater than (osd_max_object_size - rgw_gc_max_deferred_entries_size - 1K). - default: 131068_K + greater than osd_max_object_size - 1K. + default: 134068_K services: - rgw see_also: - osd_max_object_size - - rgw_gc_max_deferred_entries_size - with_legacy: true -- name: rgw_gc_max_deferred - type: uint - level: advanced - desc: Number of maximum deferred data entries to be stored in queue for gc - default: 50 - services: - - rgw with_legacy: true - name: rgw_s3_success_create_obj_status type: int diff --git a/src/rgw/driver/rados/rgw_gc.cc b/src/rgw/driver/rados/rgw_gc.cc index 8a9e464f1dd..de52f29936d 100644 --- a/src/rgw/driver/rados/rgw_gc.cc +++ b/src/rgw/driver/rados/rgw_gc.cc @@ -50,8 +50,8 @@ void RGWGC::initialize(CephContext *_cct, RGWRados *_store, optional_yield y) { //version = 1 -> marked ready for transition librados::ObjectWriteOperation op; op.create(false); - const uint64_t queue_size = cct->_conf->rgw_gc_max_queue_size, num_deferred_entries = cct->_conf->rgw_gc_max_deferred; - gc_log_init2(op, queue_size, num_deferred_entries); + const uint64_t queue_size = cct->_conf->rgw_gc_max_queue_size; + gc_log_init2(op, queue_size, 0); store->gc_operate(this, obj_names[i], std::move(op), y); } } @@ -139,90 +139,6 @@ int RGWGC::send_chain(const cls_rgw_obj_chain& chain, const string& tag, optiona return store->gc_operate(this, obj_names[i], std::move(set_entry_op), y); } -struct defer_chain_state { - librados::AioCompletion* completion = nullptr; - // TODO: hold a reference on the state in RGWGC to avoid use-after-free if - // RGWGC destructs before this completion fires - RGWGC* gc = nullptr; - cls_rgw_gc_obj_info info; - - ~defer_chain_state() { - if (completion) { - completion->release(); - } - } -}; - -static void async_defer_callback(librados::completion_t, void* arg) -{ - std::unique_ptr state{static_cast(arg)}; - if (state->completion->get_return_value() == -ECANCELED) { - state->gc->on_defer_canceled(state->info); - } -} - -void RGWGC::on_defer_canceled(const cls_rgw_gc_obj_info& info) -{ - const std::string& tag = info.tag; - const int i = tag_index(tag); - - // ECANCELED from cls_version_check() tells us that we've transitioned - transitioned_objects_cache[i] = true; - - ObjectWriteOperation op; - cls_rgw_gc_queue_defer_entry(op, cct->_conf->rgw_gc_obj_min_wait, info); - cls_rgw_gc_remove(op, {tag}); - - aio_completion_ptr c{librados::Rados::aio_create_completion(nullptr, nullptr)}; - - store->gc_aio_operate(obj_names[i], c.get(), &op); -} - -int RGWGC::async_defer_chain(const string& tag, const cls_rgw_obj_chain& chain) -{ - const int i = tag_index(tag); - cls_rgw_gc_obj_info info; - info.chain = chain; - info.tag = tag; - - // if we've transitioned this shard object, we can rely on the cls_rgw_gc queue - if (transitioned_objects_cache[i]) { - ObjectWriteOperation op; - cls_rgw_gc_queue_defer_entry(op, cct->_conf->rgw_gc_obj_min_wait, info); - - // this tag may still be present in omap, so remove it once the cls_rgw_gc - // enqueue succeeds - cls_rgw_gc_remove(op, {tag}); - - aio_completion_ptr c{librados::Rados::aio_create_completion(nullptr, nullptr)}; - - int ret = store->gc_aio_operate(obj_names[i], c.get(), &op); - return ret; - } - - // if we haven't seen the transition yet, write the defer to omap with cls_rgw - ObjectWriteOperation op; - - // assert that we haven't initialized cls_rgw_gc queue. this prevents us - // from writing new entries to omap after the transition - gc_log_defer1(op, cct->_conf->rgw_gc_obj_min_wait, info); - - // prepare a callback to detect the transition via ECANCELED from cls_version_check() - auto state = std::make_unique(); - state->gc = this; - state->info.chain = chain; - state->info.tag = tag; - state->completion = librados::Rados::aio_create_completion( - state.get(), async_defer_callback); - - int ret = store->gc_aio_operate(obj_names[i], state->completion, &op); - if (ret == 0) { - // coverity[leaked_storage:SUPPRESS] - state.release(); // release ownership until async_defer_callback() - } - return ret; -} - int RGWGC::remove(int index, const std::vector& tags, AioCompletion **pc, optional_yield y) { ObjectWriteOperation op; diff --git a/src/rgw/driver/rados/rgw_gc.h b/src/rgw/driver/rados/rgw_gc.h index b7a11820427..57fc084b63b 100644 --- a/src/rgw/driver/rados/rgw_gc.h +++ b/src/rgw/driver/rados/rgw_gc.h @@ -52,12 +52,6 @@ public: std::vector transitioned_objects_cache; std::tuple> send_split_chain(const cls_rgw_obj_chain& chain, const std::string& tag, optional_yield y); - // asynchronously defer garbage collection on an object that's still being read - int async_defer_chain(const std::string& tag, const cls_rgw_obj_chain& info); - - // callback for when async_defer_chain() fails with ECANCELED - void on_defer_canceled(const cls_rgw_gc_obj_info& info); - int remove(int index, const std::vector& tags, librados::AioCompletion **pc, optional_yield y); int remove(int index, int num_entries, optional_yield y); diff --git a/src/rgw/driver/rados/rgw_rados.cc b/src/rgw/driver/rados/rgw_rados.cc index c495903d904..2a8127a8817 100644 --- a/src/rgw/driver/rados/rgw_rados.cc +++ b/src/rgw/driver/rados/rgw_rados.cc @@ -6449,43 +6449,6 @@ int RGWRados::bucket_resync_encrypted_multipart(const DoutPrefixProvider* dpp, return 0; } -int RGWRados::defer_gc(const DoutPrefixProvider *dpp, RGWObjectCtx* octx, RGWBucketInfo& bucket_info, const rgw_obj& obj, optional_yield y) -{ - std::string oid, key; - get_obj_bucket_and_oid_loc(obj, oid, key); - if (!octx) - return 0; - - RGWObjState *state = NULL; - RGWObjManifest *manifest = nullptr; - - int r = get_obj_state(dpp, octx, bucket_info, obj, &state, &manifest, false, y); - if (r < 0) - return r; - - if (!state->is_atomic) { - ldpp_dout(dpp, 20) << "state for obj=" << obj << " is not atomic, not deferring gc operation" << dendl; - return -EINVAL; - } - - string tag; - - if (state->tail_tag.length() > 0) { - tag = state->tail_tag.c_str(); - } else if (state->obj_tag.length() > 0) { - tag = state->obj_tag.c_str(); - } else { - ldpp_dout(dpp, 20) << "state->obj_tag is empty, not deferring gc operation" << dendl; - return -EINVAL; - } - - ldpp_dout(dpp, 0) << "defer chain tag=" << tag << dendl; - - cls_rgw_obj_chain chain; - update_gc_chain(dpp, state->obj, *manifest, &chain); - return gc->async_defer_chain(tag, chain); -} - void RGWRados::remove_rgw_head_obj(ObjectWriteOperation& op) { list prefixes; diff --git a/src/rgw/driver/rados/rgw_rados.h b/src/rgw/driver/rados/rgw_rados.h index d8fe665fcf3..d1b61933606 100644 --- a/src/rgw/driver/rados/rgw_rados.h +++ b/src/rgw/driver/rados/rgw_rados.h @@ -1630,7 +1630,6 @@ public: int list_gc_objs(int *index, std::string& marker, uint32_t max, bool expired_only, std::list& result, bool *truncated, bool& processing_queue); int process_gc(bool expired_only, optional_yield y); bool process_expired_objects(const DoutPrefixProvider *dpp, optional_yield y); - int defer_gc(const DoutPrefixProvider *dpp, RGWObjectCtx* ctx, RGWBucketInfo& bucket_info, const rgw_obj& obj, optional_yield y); int process_lc(const std::unique_ptr& optional_bucket); diff --git a/src/test/cls_rgw_gc/test_cls_rgw_gc.cc b/src/test/cls_rgw_gc/test_cls_rgw_gc.cc index 6014542a324..44cc464ffd0 100644 --- a/src/test/cls_rgw_gc/test_cls_rgw_gc.cc +++ b/src/test/cls_rgw_gc/test_cls_rgw_gc.cc @@ -207,161 +207,6 @@ TEST(cls_rgw_gc, gc_queue_ops2) ASSERT_EQ("chain-1", it.tag); } -#if 0 // TODO: fix or remove defer_gc() -TEST(cls_rgw_gc, gc_queue_ops3) -{ - //Testing remove queue entries - string queue_name = "my-third-queue"; - uint64_t queue_size = 501, num_urgent_data_entries = 10; - librados::ObjectWriteOperation op; - op.create(true); - cls_rgw_gc_queue_init(op, queue_size, num_urgent_data_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &op)); - - uint64_t size = 0; - int ret = cls_rgw_gc_queue_get_capacity(ioctx, queue_name, size); - ASSERT_EQ(0, ret); - ASSERT_EQ(size, queue_size); - - //Test remove queue, when queue is empty - librados::ObjectWriteOperation remove_op; - string marker1; - uint64_t num_entries = 2; - cls_rgw_gc_queue_remove_entries(remove_op, num_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &remove_op)); - - cls_rgw_gc_obj_info defer_info; - - //Test enqueue - for (int i = 0; i < 2; i++) { - string tag = "chain-" + to_string(i); - librados::ObjectWriteOperation op; - cls_rgw_gc_obj_info info; - - cls_rgw_obj obj1, obj2; - create_obj(obj1, i, 1); - create_obj(obj2, i, 2); - info.chain.objs.push_back(obj1); - info.chain.objs.push_back(obj2); - - info.tag = tag; - cls_rgw_gc_queue_enqueue(op, 5, info); - ASSERT_EQ(0, ioctx.operate(queue_name, &op)); - if (i == 0) - defer_info = info; - } - - //Test defer entry for 1st element - librados::ObjectWriteOperation defer_op; - cls_rgw_gc_queue_defer_entry(defer_op, 10, defer_info); - ASSERT_EQ(0, ioctx.operate(queue_name, &defer_op)); - - //Test list queue - list list_info1, list_info2; - string marker, next_marker; - uint64_t max = 2; - bool expired_only = false, truncated; - cls_rgw_gc_queue_list_entries(ioctx, queue_name, marker, max, expired_only, list_info1, &truncated, next_marker); - ASSERT_EQ(2, list_info1.size()); - - int i = 0; - for (auto it : list_info1) { - std::cerr << "[ ] list info tag = " << it.tag << std::endl; - if (i == 0) { - ASSERT_EQ("chain-1", it.tag); - } - if (i == 1) { - ASSERT_EQ("chain-0", it.tag); - } - i++; - } - - //Test remove entries - num_entries = 2; - cls_rgw_gc_queue_remove_entries(remove_op, num_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &remove_op)); - - //Test list queue again - cls_rgw_gc_queue_list_entries(ioctx, queue_name, marker, max, expired_only, list_info2, &truncated, next_marker); - ASSERT_EQ(0, list_info2.size()); - -} - -TEST(cls_rgw_gc, gc_queue_ops4) -{ - //Testing remove queue entries - string queue_name = "my-fourth-queue"; - uint64_t queue_size = 501, num_urgent_data_entries = 10; - librados::ObjectWriteOperation op; - op.create(true); - cls_rgw_gc_queue_init(op, queue_size, num_urgent_data_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &op)); - - uint64_t size = 0; - int ret = cls_rgw_gc_queue_get_capacity(ioctx, queue_name, size); - ASSERT_EQ(0, ret); - ASSERT_EQ(size, queue_size); - - //Test remove queue, when queue is empty - librados::ObjectWriteOperation remove_op; - string marker1; - uint64_t num_entries = 2; - - cls_rgw_gc_queue_remove_entries(remove_op, num_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &remove_op)); - - cls_rgw_gc_obj_info defer_info; - - //Test enqueue - for (int i = 0; i < 2; i++) { - string tag = "chain-" + to_string(i); - librados::ObjectWriteOperation op; - cls_rgw_gc_obj_info info; - - cls_rgw_obj obj1, obj2; - create_obj(obj1, i, 1); - create_obj(obj2, i, 2); - info.chain.objs.push_back(obj1); - info.chain.objs.push_back(obj2); - - info.tag = tag; - cls_rgw_gc_queue_enqueue(op, 5, info); - ASSERT_EQ(0, ioctx.operate(queue_name, &op)); - defer_info = info; - } - - //Test defer entry for last element - librados::ObjectWriteOperation defer_op; - cls_rgw_gc_queue_defer_entry(defer_op, 10, defer_info); - ASSERT_EQ(0, ioctx.operate(queue_name, &defer_op)); - - //Test list queue - list list_info1, list_info2; - string marker, next_marker; - uint64_t max = 2; - bool expired_only = false, truncated; - cls_rgw_gc_queue_list_entries(ioctx, queue_name, marker, max, expired_only, list_info1, &truncated, next_marker); - ASSERT_EQ(2, list_info1.size()); - - int i = 0; - for (auto it : list_info1) { - string tag = "chain-" + to_string(i); - ASSERT_EQ(tag, it.tag); - i++; - } - - //Test remove entries - num_entries = 2; - cls_rgw_gc_queue_remove_entries(remove_op, num_entries); - ASSERT_EQ(0, ioctx.operate(queue_name, &remove_op)); - - //Test list queue again - cls_rgw_gc_queue_list_entries(ioctx, queue_name, marker, max, expired_only, list_info2, &truncated, next_marker); - ASSERT_EQ(0, list_info2.size()); - -} -#endif // defer_gc() disabled - TEST(cls_rgw_gc, gc_queue_ops5) { //Testing remove queue entries From ceabc96f164226e2e16f598023991943cccfb39d Mon Sep 17 00:00:00 2001 From: Nathan Hoad Date: Fri, 1 May 2026 09:16:20 -0400 Subject: [PATCH 044/596] doc: Update release notes for removed options. Signed-off-by: Nathan Hoad --- PendingReleaseNotes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index d50c8878abb..1d7c36b8a54 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -14,6 +14,8 @@ If the default provider is required when also using custom providers, it must be explicitly loaded in the configuration file or code (see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md). * RGW: Fixed bucket notification events so the 'x_amz_request_id' in NotificationEvent now matches the 'x_amz_request_id' returned by the corresponding S3 operation. +* RGW: The rgw_gc_max_deferred and rgw_gc_max_deferred_entries_size options have been removed, as they did not do anything. + - Updated the default for rgw_gc_max_queue_size to account for the extra space from removing rgw_gc_max_deferred_entries_size. * DASHBOARD: Removed the older landing page which was deprecated in Quincy. From ec8fad9a784efa720007838e1c8f7c4a2e64f623 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Thu, 7 May 2026 11:49:19 +0200 Subject: [PATCH 045/596] nvmeof: add missing includes Signed-off-by: Max Kellermann --- src/nvmeof/NVMeofGwMonitorClient.cc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nvmeof/NVMeofGwMonitorClient.cc b/src/nvmeof/NVMeofGwMonitorClient.cc index b90f9d50990..7f284802782 100644 --- a/src/nvmeof/NVMeofGwMonitorClient.cc +++ b/src/nvmeof/NVMeofGwMonitorClient.cc @@ -20,6 +20,7 @@ #include "include/compat.h" #include "include/stringify.h" +#include "include/types.h" // for client_t #include "include/ceph_features.h" #include "global/global_context.h" #include "global/signal_handler.h" @@ -27,6 +28,7 @@ #include "messages/MNVMeofGwBeacon.h" #include "messages/MNVMeofGwMap.h" +#include "msg/Messenger.h" #include "NVMeofGwMonitorClient.h" #include "NVMeofGwClient.h" #include "NVMeofGwMonitorGroupClient.h" From b8e3155ef0dee8bbbb8ffdacbffa71786827c02d Mon Sep 17 00:00:00 2001 From: Shubha Jain Date: Mon, 16 Feb 2026 19:33:01 +0530 Subject: [PATCH 046/596] mgr/nfs: improve cluster info implementation and fix lint regressions - Improve cluster info implementation - Fix deployment type logic - Resolve lint issues for check jobs Signed-off-by: Shubha jain --- qa/tasks/cephfs/test_nfs.py | 6 +- src/pybind/mgr/nfs/cluster.py | 160 +++++++++++++++++++------- src/pybind/mgr/nfs/tests/test_nfs.py | 7 +- src/pybind/mgr/orchestrator/module.py | 1 - 4 files changed, 129 insertions(+), 45 deletions(-) diff --git a/qa/tasks/cephfs/test_nfs.py b/qa/tasks/cephfs/test_nfs.py index 44f595e7172..28f30f25e41 100644 --- a/qa/tasks/cephfs/test_nfs.py +++ b/qa/tasks/cephfs/test_nfs.py @@ -814,14 +814,18 @@ class TestNFS(MgrTestCase): info_output = json.loads(self._nfs_cmd('cluster', 'info', self.cluster_id)) print(f'info {info_output}') info_ip = info_output[self.cluster_id].get('backend', [])[0].pop("ip") + # Pop placement since it may vary by test environment + info_output[self.cluster_id].pop("placement", {}) host_details = { self.cluster_id: { 'backend': [ { "hostname": self._sys_cmd(['hostname']).decode("utf-8").strip(), - "port": 2049 + "port": 2049, + "status": "running" } ], + "deployment_type": "standalone", "virtual_ip": None, } } diff --git a/src/pybind/mgr/nfs/cluster.py b/src/pybind/mgr/nfs/cluster.py index 5a4cf057aa2..c524ae0f564 100644 --- a/src/pybind/mgr/nfs/cluster.py +++ b/src/pybind/mgr/nfs/cluster.py @@ -2,7 +2,7 @@ import ipaddress import logging import re import socket -from typing import cast, Dict, List, Any, Union, Optional, TYPE_CHECKING +from typing import cast, Dict, List, Any, Optional, TYPE_CHECKING, Union from mgr_module import NFS_POOL_NAME as POOL_NAME from ceph.deployment.service_spec import NFSServiceSpec, PlacementSpec, IngressSpec @@ -350,56 +350,132 @@ class NFSCluster: raise ErrorResponse.wrap(e) def _show_nfs_cluster_info(self, cluster_id: str) -> Dict[str, Any]: + """ + Retrieve and format NFS cluster information including daemon status, + placement, and ingress configuration. + """ + # Get all NFS daemons completion = self.mgr.list_daemons(daemon_type='nfs') - # Here completion.result is a list DaemonDescription objects - clusters = orchestrator.raise_if_exception(completion) - backends: List[Dict[str, Union[Any]]] = [] + all_nfs_daemons = orchestrator.raise_if_exception(completion) - for cluster in clusters: - if cluster_id == cluster.service_id(): - assert cluster.hostname - try: - if cluster.ip: - ip = cluster.ip - else: - c = self.mgr.get_hosts() - orchestrator.raise_if_exception(c) - hosts = [h for h in c.result or [] - if h.hostname == cluster.hostname] - if hosts: - ip = resolve_ip(hosts[0].addr) - else: - # sigh - ip = resolve_ip(cluster.hostname) - backends.append({ - "hostname": cluster.hostname, - "ip": ip, - "port": cluster.ports[0] if cluster.ports else None - }) - except orchestrator.OrchestratorError: - continue + # Filter daemons for this cluster + cluster_daemons = [d for d in all_nfs_daemons if d.service_id() == cluster_id] - r: Dict[str, Any] = { - 'virtual_ip': None, - 'backend': backends, - } - sc = self.mgr.describe_service(service_type='ingress') - services = orchestrator.raise_if_exception(sc) - for i in services: - spec = cast(IngressSpec, i.spec) - if spec.backend_service == f'nfs.{cluster_id}': - r['virtual_ip'] = i.virtual_ip.split('/')[0] if i.virtual_ip else None - if i.ports: - r['port'] = i.ports[0] - if len(i.ports) > 1: - r['monitor_port'] = i.ports[1] + # Cache hosts data to avoid O(n) orchestrator calls + hosts_map = {} + try: + hosts_completion = self.mgr.get_hosts() + hosts = orchestrator.raise_if_exception(hosts_completion) + hosts_map = {h.hostname: h for h in hosts} + except orchestrator.OrchestratorError: + log.debug("Failed to get hosts for IP resolution") + + # Determine ingress configuration + ingress_mode: Optional[IngressType] = None + virtual_ip: Optional[str] = None + ingress_port: Optional[int] = None + monitor_port: Optional[int] = None + + sc = self.mgr.describe_service( + service_type='ingress', + service_name=f'ingress.nfs.{cluster_id}' + ) + try: + ingress_services = orchestrator.raise_if_exception(sc) + if ingress_services: + svc = ingress_services[0] + spec = cast(IngressSpec, svc.spec) + virtual_ip = svc.virtual_ip.split('/')[0] if svc.virtual_ip else None if spec.keepalive_only: ingress_mode = IngressType.keepalive_only elif spec.enable_haproxy_protocol: ingress_mode = IngressType.haproxy_protocol else: ingress_mode = IngressType.haproxy_standard - r['ingress_mode'] = ingress_mode.value + if svc.ports: + ingress_port = svc.ports[0] + if len(svc.ports) > 1: + monitor_port = svc.ports[1] + except orchestrator.OrchestratorError: + # No ingress service found for this cluster + log.debug(f"No ingress service found for NFS cluster {cluster_id}") + + # Build backend list with daemon information + backends: List[Dict[str, Any]] = [] + for daemon in cluster_daemons: + if not daemon.hostname: + continue + + try: + # Resolve daemon IP + if daemon.ip: + ip = daemon.ip + elif daemon.hostname in hosts_map: + # Use cached host data + ip = resolve_ip(hosts_map[daemon.hostname].addr) + else: + # Fallback to hostname resolution + ip = resolve_ip(daemon.hostname) + + # Get daemon status + status = orchestrator.DaemonDescriptionStatus.to_str(daemon.status) + + backends.append({ + "hostname": daemon.hostname, + "ip": ip, + "port": daemon.ports[0] if daemon.ports and len(daemon.ports) > 0 else None, + "status": status + }) + except orchestrator.OrchestratorError: + log.warning( + "Failed to get info for NFS daemon" + f" on {daemon.hostname} in cluster {cluster_id}") + continue + + # Sort backends by hostname for consistent output + backends.sort(key=lambda x: x["hostname"]) + + # Determine deployment type based on ingress configuration and actual daemon count + deployment_type = "standalone" + placement = None + + # Get NFS service spec for placement information first + nfs_sc = self.mgr.describe_service( + service_type='nfs', + service_name=f'nfs.{cluster_id}' + ) + nfs_services = orchestrator.raise_if_exception(nfs_sc) + for svc in nfs_services: + if svc.spec.service_id == cluster_id: + placement = svc.spec.placement + break + + if ingress_mode: + # Determine deployment type from placement spec (source of truth) + # Note: Using placement.count instead of len(backends) to avoid race conditions + if placement and placement.count and placement.count > 1: + deployment_type = "active-active" + elif len(backends) > 1: + # Fallback to actual daemon count if placement.count not set + deployment_type = "active-active" + else: + deployment_type = "active-passive" + + # Build result dictionary + r: Dict[str, Any] = { + 'deployment_type': deployment_type, + 'virtual_ip': virtual_ip, + 'backend': backends, + 'placement': placement.to_json() if placement else {}, + } + + # Add ingress configuration to result + if ingress_mode: + r['ingress_mode'] = ingress_mode.value + if ingress_port is not None: + r['port'] = ingress_port + if monitor_port is not None: + r['monitor_port'] = monitor_port log.debug("Successfully fetched %s info: %s", cluster_id, r) return r diff --git a/src/pybind/mgr/nfs/tests/test_nfs.py b/src/pybind/mgr/nfs/tests/test_nfs.py index 57db70a2002..4104fab05bc 100644 --- a/src/pybind/mgr/nfs/tests/test_nfs.py +++ b/src/pybind/mgr/nfs/tests/test_nfs.py @@ -1430,7 +1430,12 @@ EXPORT { cluster = NFSCluster(nfs_mod) out = cluster.show_nfs_cluster_info(self.cluster_id) - assert out == {"foo": {"virtual_ip": None, "backend": []}} + assert out == {"foo": { + "deployment_type": "standalone", + "virtual_ip": None, + "backend": [], + "placement": {} + }} def test_cluster_info(self): self._do_mock_test(self._do_test_cluster_info) diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index 5b8e9a6458f..431afe78876 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -2625,4 +2625,3 @@ Usage: completion = self.update_service(service_type.value, service_type.name, image) raise_if_exception(completion) return HandleCommandResult(stdout=completion.result_str()) - From e334498be3e18cde0d466bb0f0f2e27fe2dd66ba Mon Sep 17 00:00:00 2001 From: dheart Date: Thu, 16 Apr 2026 20:34:42 +0800 Subject: [PATCH 047/596] tool/ceph-kvstore-tool: add --pretty-binary-key option Signed-off-by: dheart --- src/test/cli/ceph-kvstore-tool/help.t | 2 + src/tools/ceph_kvstore_tool.cc | 121 ++++++++++++++++++++++---- src/tools/kvstore_tool.cc | 14 ++- src/tools/kvstore_tool.h | 2 + 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/test/cli/ceph-kvstore-tool/help.t b/src/test/cli/ceph-kvstore-tool/help.t index f8fb009ca59..67db2e87e11 100644 --- a/src/test/cli/ceph-kvstore-tool/help.t +++ b/src/test/cli/ceph-kvstore-tool/help.t @@ -1,6 +1,8 @@ $ ceph-kvstore-tool --help Usage: ceph-kvstore-tool command [args...] + Options: + --pretty-binary-key Use/dump binary keys in a print pretty format Commands: list [prefix] list-crc [prefix] diff --git a/src/tools/ceph_kvstore_tool.cc b/src/tools/ceph_kvstore_tool.cc index 0029c3804b3..fa4afc66d76 100644 --- a/src/tools/ceph_kvstore_tool.cc +++ b/src/tools/ceph_kvstore_tool.cc @@ -26,6 +26,7 @@ #include "global/global_context.h" #include "global/global_init.h" +#include "common/pretty_binary.h" #include "kvstore_tool.h" using namespace std; @@ -34,6 +35,8 @@ void usage(const char *pname) { std::cout << "Usage: " << pname << " command [args...]\n" << "\n" + << "Options:\n" + << " --pretty-binary-key Use/dump binary keys in a print pretty format\n" << "Commands:\n" << " list [prefix]\n" << " list-crc [prefix]\n" @@ -56,6 +59,33 @@ void usage(const char *pname) << std::endl; } +std::string format_key(const std::string &key, bool pretty_binary_key = false) +{ + if (pretty_binary_key) { + return pretty_binary_string(key); + } + return url_escape(key); +} + +std::string parse_key(const std::string &key, bool pretty_binary_key, std::string &err) +{ + err.clear(); + try { + if (pretty_binary_key) + return pretty_binary_string_reverse(key); + return url_unescape(key); + } catch (const std::invalid_argument &e) { + std::ostringstream oss; + oss << "invalid pretty binary string: " << e.what(); + err = oss.str(); + } catch (const std::runtime_error &e) + { + err = e.what(); + } + + return ""; +} + int main(int argc, const char *argv[]) { auto args = argv_to_vec(argc, argv); @@ -63,6 +93,17 @@ int main(int argc, const char *argv[]) cerr << argv[0] << ": -h or --help for usage" << std::endl; exit(1); } + + bool pretty_binary_key = [&args] { //pick --pretty-binary-key from args + auto arg_it = std::find_if(args.begin(), args.end(), + [](const char* val) {return strcmp(val, "--pretty-binary-key") == 0;}); + if (arg_it != args.end()) { + args.erase(arg_it); + return true; + } + return false; + }(); + if (ceph_argparse_need_usage(args)) { usage(argv[0]); exit(0); @@ -131,13 +172,13 @@ int main(int argc, const char *argv[]) prefix = url_unescape(argv[4]); bool do_crc = (cmd == "list-crc"); - st.list(prefix, do_crc, false); + st.list(prefix, do_crc, pretty_binary_key, false); } else if (cmd == "dump") { string prefix; if (argc > 4) prefix = url_unescape(argv[4]); - st.list(prefix, false, true); + st.list(prefix, false, pretty_binary_key, true); } else if (cmd == "exists") { string key; @@ -146,11 +187,17 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - if (argc > 5) - key = url_unescape(argv[5]); + if (argc > 5) { + std::string err; + key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } + } bool ret = st.exists(prefix, key); - std::cout << "(" << url_escape(prefix) << ", " << url_escape(key) << ") " + std::cout << "(" << url_escape(prefix) << ", " << format_key(key, pretty_binary_key) << ") " << (ret ? "exists" : "does not exist") << std::endl; return (ret ? 0 : 1); @@ -161,11 +208,16 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string key(url_unescape(argv[5])); + std::string err; + std::string key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } bool exists = false; bufferlist bl = st.get(prefix, key, exists); - std::cout << "(" << url_escape(prefix) << ", " << url_escape(key) << ")"; + std::cout << "(" << url_escape(prefix) << ", " << format_key(key, pretty_binary_key) << ")"; if (!exists) { std::cout << " does not exist" << std::endl; return 1; @@ -208,11 +260,16 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string key(url_unescape(argv[5])); + std::string err; + string key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } bool exists = false; bufferlist bl = st.get(prefix, key, exists); - std::cout << "(" << url_escape(prefix) << ", " << url_escape(key) << ") "; + std::cout << "(" << url_escape(prefix) << ", " << format_key(key, pretty_binary_key) << ") "; if (!exists) { std::cout << " does not exist" << std::endl; return 1; @@ -230,16 +287,21 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string key(url_unescape(argv[5])); + std::string err; + string key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } bool exists = false; bufferlist bl = st.get(prefix, key, exists); if (!exists) { - std::cerr << "(" << url_escape(prefix) << "," << url_escape(key) + std::cerr << "(" << url_escape(prefix) << "," << format_key(key, pretty_binary_key) << ") does not exist" << std::endl; return 1; } - std::cout << "(" << url_escape(prefix) << "," << url_escape(key) + std::cout << "(" << url_escape(prefix) << "," << format_key(key, pretty_binary_key) << ") size " << byte_u_t(bl.length()) << std::endl; } else if (cmd == "set") { @@ -248,7 +310,12 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string key(url_unescape(argv[5])); + std::string err; + string key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } string subcmd(argv[6]); bufferlist val; @@ -275,7 +342,7 @@ int main(int argc, const char *argv[]) bool ret = st.set(prefix, key, val); if (!ret) { std::cerr << "error setting (" - << url_escape(prefix) << "," << url_escape(key) << ")" << std::endl; + << url_escape(prefix) << "," << format_key(key, pretty_binary_key) << ")" << std::endl; return 1; } } else if (cmd == "rm") { @@ -284,12 +351,17 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string key(url_unescape(argv[5])); + std::string err; + string key = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } bool ret = st.rm(prefix, key); if (!ret) { std::cerr << "error removing (" - << url_escape(prefix) << "," << url_escape(key) << ")" + << url_escape(prefix) << "," << format_key(key, pretty_binary_key) << ")" << std::endl; return 1; } @@ -338,7 +410,7 @@ int main(int argc, const char *argv[]) return 1; } std::ofstream fs(argv[4]); - uint32_t crc = st.traverse(string(), true, false, &fs); + uint32_t crc = st.traverse(string(), true, pretty_binary_key, false, &fs); std::cout << "store at '" << argv[4] << "' crc " << crc << std::endl; } else if (cmd == "compact") { @@ -356,8 +428,19 @@ int main(int argc, const char *argv[]) return 1; } string prefix(url_unescape(argv[4])); - string start(url_unescape(argv[5])); - string end(url_unescape(argv[6])); + std::string err; + string start = parse_key(argv[5], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } + string end = parse_key(argv[6], pretty_binary_key, err); + if (!err.empty()) { + std::cerr << err << std::endl; + return 1; + } + std::cout << "(" << url_escape(prefix) << "," << format_key(start, pretty_binary_key) + << " ~ " << format_key(end, pretty_binary_key) << ")" << std::endl; st.compact_range(prefix, start, end); } else if (cmd == "stats") { st.print_stats(); diff --git a/src/tools/kvstore_tool.cc b/src/tools/kvstore_tool.cc index 7d9a9fb3f8e..a1970b903b2 100644 --- a/src/tools/kvstore_tool.cc +++ b/src/tools/kvstore_tool.cc @@ -83,6 +83,7 @@ int StoreTool::load_bluestore(const string& path, bool read_only, bool to_repair uint32_t StoreTool::traverse(const string& prefix, const bool do_crc, + const bool pretty_binary_key, const bool do_value_dump, ostream *out) { @@ -100,8 +101,13 @@ uint32_t StoreTool::traverse(const string& prefix, if (!prefix.empty() && (rk.first != prefix)) break; - if (out) - *out << url_escape(rk.first) << "\t" << url_escape(rk.second); + if (out) { + if (pretty_binary_key) { + *out << url_escape(rk.first) << "\t" << pretty_binary_string(rk.second); + } else { + *out << url_escape(rk.first) << "\t" << url_escape(rk.second); + } + } if (do_crc) { bufferlist bl; bl.append(rk.first); @@ -130,9 +136,9 @@ uint32_t StoreTool::traverse(const string& prefix, } void StoreTool::list(const string& prefix, const bool do_crc, - const bool do_value_dump) + const bool pretty_binary_key, const bool do_value_dump) { - traverse(prefix, do_crc, do_value_dump,& std::cout); + traverse(prefix, do_crc, pretty_binary_key, do_value_dump,& std::cout); } bool StoreTool::exists(const string& prefix) diff --git a/src/tools/kvstore_tool.h b/src/tools/kvstore_tool.h index 11ad30b5f1f..ea5e7dc79be 100644 --- a/src/tools/kvstore_tool.h +++ b/src/tools/kvstore_tool.h @@ -42,10 +42,12 @@ public: bool need_stats = false); uint32_t traverse(const std::string& prefix, const bool do_crc, + const bool pretty_binary_key, const bool do_value_dump, std::ostream *out); void list(const std::string& prefix, const bool do_crc, + const bool pretty_binary_key, const bool do_value_dump); bool exists(const std::string& prefix); bool exists(const std::string& prefix, const std::string& key); From f8d3db622a5a7d105b8536992425106f7db58882 Mon Sep 17 00:00:00 2001 From: Jacques Heunis Date: Wed, 13 May 2026 09:14:05 +0000 Subject: [PATCH 048/596] rgw: Fix ops logs sometimes having several entries per line. Although not explicitly documented, the RGW ops log is generally formatted with one entry per line. This makes it work well with log shipping/ingestion services (many of which default to treating each line as a separate entry) and in particular works well with log servers that index based on the available JSON fields. The current implementation separates the log call from the resulting disk IO: Logs are written to a buffer and a separate thread flushes them to disk (possibly in batches). The current code appends a newline only at the end of the batch being flushed to disk and in many cases when under load this means that several log entries are concatenated onto a single line, which complicates attempts to process those logs. This PR separates the addition of a new line from the flush to disk, appending a newline after every log entry but still only flushing at the end of the batch to avoid additional IO overhead. Fixes: https://tracker.ceph.com/issues/76566 Signed-off-by: Jacques Heunis --- src/rgw/rgw_log.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_log.cc b/src/rgw/rgw_log.cc index 27580cce267..ea849ea2e6e 100644 --- a/src/rgw/rgw_log.cc +++ b/src/rgw/rgw_log.cc @@ -420,12 +420,13 @@ void OpsLogFile::flush() std::this_thread::sleep_for(std::chrono::seconds(sleep_time_secs)); try_num++; } else { + file << '\n'; break; } } } flush_buffer.clear(); - file << std::endl; + file.flush(); } void* OpsLogFile::entry() { From 59b1191ed7defcf39a88ba12d5bfb3a0b8a279da Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Wed, 13 May 2026 19:49:11 +0530 Subject: [PATCH 049/596] python-common: Adding precheck for placement.count_per_host in NFSServiceSpec NFS service specs should not allow placement.count_per_host. Fixes: https://tracker.ceph.com/issues/76579 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/tests/services/test_nfs.py | 9 +++++++++ src/python-common/ceph/deployment/service_spec.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/src/pybind/mgr/cephadm/tests/services/test_nfs.py b/src/pybind/mgr/cephadm/tests/services/test_nfs.py index 376fdea25e3..5079a517ebd 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_nfs.py +++ b/src/pybind/mgr/cephadm/tests/services/test_nfs.py @@ -589,6 +589,15 @@ class TestNFS: assert "NFS_RDMA_Port" not in ganesha_conf +def test_nfs_placement_count_per_host_rejected(): + spec = NFSServiceSpec( + service_id='mynfs', + placement=PlacementSpec(hosts=['h1'], count_per_host=1), + ) + with pytest.raises(SpecValidationError, match="count_per_host.*not supported"): + spec.validate() + + def test_nfs_colocation_ports_validation(): """Test validation of colocation_ports in NFSServiceSpec""" # Valid case: correct number of colocation_ports (count=3, need 2 additional) diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 857446eb115..8e274368348 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -1529,6 +1529,11 @@ class NFSServiceSpec(ServiceSpec): def validate(self) -> None: super(NFSServiceSpec, self).validate() + if self.placement is not None and self.placement.count_per_host is not None: + raise SpecValidationError( + "Placement 'count_per_host' is not supported for nfs service." + ) + if self.virtual_ip and (self.ip_addrs or self.networks): raise SpecValidationError("Invalid NFS spec: Cannot set virtual_ip and " f"{'ip_addrs' if self.ip_addrs else 'networks'} fields") From 411ab6e86576f556cbfc05057186ab1fc1b353b3 Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Thu, 2 Apr 2026 15:31:16 -0400 Subject: [PATCH 050/596] rgw/multi: Add GenCache to ShardCache The GenCache will keep track of per-bucket sync information, like if we have had to synthesize a change-per-shard as a result of hitting the wrong generation. Templatize tests. Fixes: https://tracker.ceph.com/issues/75786 Signed-off-by: Adam C. Emerson --- src/rgw/driver/rados/rgw_data_sync.cc | 22 +- src/rgw/rgw_bucket_sync_cache.h | 75 +++-- src/test/rgw/test_rgw_bucket_sync_cache.cc | 331 ++++++++++++++++----- 3 files changed, 319 insertions(+), 109 deletions(-) diff --git a/src/rgw/driver/rados/rgw_data_sync.cc b/src/rgw/driver/rados/rgw_data_sync.cc index 3b742b85706..abe1e611ada 100644 --- a/src/rgw/driver/rados/rgw_data_sync.cc +++ b/src/rgw/driver/rados/rgw_data_sync.cc @@ -1426,7 +1426,7 @@ public: class RGWDataSyncSingleEntryCR : public RGWCoroutine { RGWDataSyncCtx *sc; RGWDataSyncEnv *sync_env; - rgw::bucket_sync::Handle state; // cached bucket-shard state + rgw::bucket_sync::ShardHandle state; // cached bucket-shard state rgw_data_sync_obligation obligation; // input obligation std::optional complete; // obligation to complete uint32_t obligation_counter = 0; @@ -1438,7 +1438,7 @@ class RGWDataSyncSingleEntryCR : public RGWCoroutine { ceph::real_time progress; int sync_status = 0; public: - RGWDataSyncSingleEntryCR(RGWDataSyncCtx *_sc, rgw::bucket_sync::Handle state, + RGWDataSyncSingleEntryCR(RGWDataSyncCtx *_sc, rgw::bucket_sync::ShardHandle state, rgw_data_sync_obligation _obligation, RGWDataSyncShardMarkerTrack *_marker_tracker, const rgw_raw_obj& error_repo, @@ -1641,7 +1641,7 @@ RGWCoroutine* data_sync_single_entry(RGWDataSyncCtx *sc, const rgw_bucket_shard& const std::string marker, ceph::real_time timestamp, boost::intrusive_ptr lease_cr, - boost::intrusive_ptr bucket_shard_cache, + boost::intrusive_ptr bucket_shard_cache, RGWDataSyncShardMarkerTrack* marker_tracker, rgw_raw_obj error_repo, RGWSyncTraceNodeRef& tn, @@ -1674,7 +1674,7 @@ class RGWDataFullSyncSingleEntryCR : public RGWCoroutine { rgw_raw_obj error_repo; ceph::real_time timestamp; boost::intrusive_ptr lease_cr; - boost::intrusive_ptr bucket_shard_cache; + boost::intrusive_ptr bucket_shard_cache; RGWDataSyncShardMarkerTrack* marker_tracker; RGWSyncTraceNodeRef tn; rgw_bucket_index_marker_info remote_info; @@ -1689,7 +1689,7 @@ public: RGWDataFullSyncSingleEntryCR(RGWDataSyncCtx *_sc, const rgw_pool& _pool, const rgw_bucket_shard& _source_bs, const std::string& _key, const rgw_data_sync_status& _sync_status, const rgw_raw_obj& _error_repo, ceph::real_time _timestamp, boost::intrusive_ptr _lease_cr, - boost::intrusive_ptr _bucket_shard_cache, + boost::intrusive_ptr _bucket_shard_cache, RGWDataSyncShardMarkerTrack* _marker_tracker, RGWSyncTraceNodeRef& _tn) : RGWCoroutine(_sc->cct), sc(_sc), sync_env(_sc->env), pool(_pool), source_bs(_source_bs), key(_key), @@ -1791,7 +1791,7 @@ protected: boost::intrusive_ptr lease_cr; const rgw_data_sync_status& sync_status; RGWObjVersionTracker& objv; - boost::intrusive_ptr bucket_shard_cache; + boost::intrusive_ptr bucket_shard_cache; std::optional marker_tracker; RGWRadosGetOmapValsCR::ResultPtr omapvals; @@ -1818,7 +1818,7 @@ protected: boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, - const boost::intrusive_ptr& bucket_shard_cache) + const boost::intrusive_ptr& bucket_shard_cache) : RGWCoroutine(_sc->cct), sc(_sc), pool(pool), shard_id(shard_id), sync_marker(sync_marker), tn(tn), status_oid(status_oid), error_repo(error_repo), lease_cr(std::move(lease_cr)), @@ -1846,7 +1846,7 @@ public: const string& status_oid, const rgw_raw_obj& error_repo, boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, - const boost::intrusive_ptr& bucket_shard_cache) + const boost::intrusive_ptr& bucket_shard_cache) : RGWDataBaseSyncShardCR(sc, pool, shard_id, sync_marker, tn, status_oid, error_repo, std::move(lease_cr), sync_status, objv, bucket_shard_cache) {} @@ -1999,7 +1999,7 @@ public: const string& status_oid, const rgw_raw_obj& error_repo, boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, - const boost::intrusive_ptr& bucket_shard_cache, + const boost::intrusive_ptr& bucket_shard_cache, ceph::mutex& inc_lock, bc::flat_set& modified_shards) : RGWDataBaseSyncShardCR(sc, pool, shard_id, sync_marker, tn, @@ -2221,8 +2221,8 @@ class RGWDataSyncShardCR : public RGWCoroutine { // target number of entries to cache before recycling idle ones static constexpr size_t target_cache_size = 256; - boost::intrusive_ptr bucket_shard_cache { - rgw::bucket_sync::Cache::create(target_cache_size) }; + boost::intrusive_ptr bucket_shard_cache { + rgw::bucket_sync::ShardCache::create(target_cache_size) }; boost::intrusive_ptr lease_cr; boost::intrusive_ptr lease_stack; diff --git a/src/rgw/rgw_bucket_sync_cache.h b/src/rgw/rgw_bucket_sync_cache.h index 01ebafe0727..7b206994d89 100644 --- a/src/rgw/rgw_bucket_sync_cache.h +++ b/src/rgw/rgw_bucket_sync_cache.h @@ -17,13 +17,15 @@ #include #include "common/intrusive_lru.h" #include "rgw_data_sync.h" +#include "common/ceph_time.h" namespace rgw::bucket_sync { -// per bucket-shard state cached by DataSyncShardCR -struct State { +// per bucket-shard (dimensioned by generation) state cached by DataSyncShardCR +struct ShardState { + using key_type = std::pair>; // the source bucket shard to sync - std::pair> key; + key_type key; // current sync obligation being processed by DataSyncSingleEntry std::optional obligation; // incremented with each new obligation @@ -31,27 +33,47 @@ struct State { // highest timestamp applied by all sources ceph::real_time progress_timestamp; - State(const std::pair>& key ) noexcept - : key(key) {} - State(const rgw_bucket_shard& shard, std::optional gen) noexcept + ShardState(const key_type& key) noexcept + : key(key) {} + ShardState(const rgw_bucket_shard& shard, std::optional gen) noexcept : key(shard, gen) {} }; +// per bucket (dimensioned by generation) state cached by DataSyncShardCR +struct GenState { + using key_type = std::pair>; + // the source bucket/generation to sync + key_type key; + // Last future generation recovery timestamp + ceph::coarse_mono_time last_future_generation_recovery = ceph::coarse_mono_clock::zero(); + + GenState(const key_type& key) noexcept + : key(key) {} + GenState(std::string bucket, std::optional gen) noexcept + : key(std::move(bucket), gen) {} +}; + +template struct Entry; +template struct EntryToKey; +template class Handle; +template using lru_config = ceph::common::intrusive_lru_config< - std::pair>, Entry, EntryToKey>; + typename State::key_type, Entry, EntryToKey>; // a recyclable cache entry -struct Entry : State, ceph::common::intrusive_lru_base { +template +struct Entry : State, ceph::common::intrusive_lru_base> { using State::State; }; +template struct EntryToKey { - using type = std::pair>; - const type& operator()(const Entry& e) { return e.key; } + using type = typename State::key_type; + const type& operator()(const Entry& e) { return e.key; } }; // use a non-atomic reference count since these aren't shared across threads @@ -59,9 +81,10 @@ template using thread_unsafe_ref_counter = boost::intrusive_ref_counter< T, boost::thread_unsafe_counter>; -// a state cache for entries within a single datalog shard -class Cache : public thread_unsafe_ref_counter { - ceph::common::intrusive_lru cache; +// A state cache for entries within a single datalog shard +template +class Cache : public thread_unsafe_ref_counter> { + ceph::common::intrusive_lru> cache; protected: // protected ctor to enforce the use of factory function create() explicit Cache(size_t target_size) { @@ -74,18 +97,19 @@ class Cache : public thread_unsafe_ref_counter { // find or create a cache entry for the given key, and return a Handle that // keeps it lru-pinned until destruction - Handle get(const rgw_bucket_shard& shard, std::optional gen); + Handle get(const auto& ...args); }; // a State handle that keeps the Cache referenced +template class Handle { - boost::intrusive_ptr cache; - boost::intrusive_ptr entry; + boost::intrusive_ptr> cache; + boost::intrusive_ptr> entry; public: Handle() noexcept = default; ~Handle() = default; - Handle(boost::intrusive_ptr cache, - boost::intrusive_ptr entry) noexcept + Handle(boost::intrusive_ptr> cache, + boost::intrusive_ptr> entry) noexcept : cache(std::move(cache)), entry(std::move(entry)) {} Handle(Handle&&) = default; Handle(const Handle&) = default; @@ -107,10 +131,21 @@ class Handle { State* operator->() const noexcept { return entry.get(); } }; -inline Handle Cache::get(const rgw_bucket_shard& shard, std::optional gen) +template +inline Handle Cache::get(const auto& ...args) { - auto result = cache.get_or_create({ shard, gen }); + static_assert( + std::is_constructible_v, + "The arguments to Cache::get must be arguments to construct " + "State"); + auto result = cache.get_or_create({args...}); return {this, std::move(result.first)}; } +using ShardHandle = Handle; +using ShardCache = Cache; + +using GenHandle = Handle; +using GenCache = Cache; + } // namespace rgw::bucket_sync diff --git a/src/test/rgw/test_rgw_bucket_sync_cache.cc b/src/test/rgw/test_rgw_bucket_sync_cache.cc index ea285c6655e..59060847269 100644 --- a/src/test/rgw/test_rgw_bucket_sync_cache.cc +++ b/src/test/rgw/test_rgw_bucket_sync_cache.cc @@ -12,71 +12,182 @@ * Foundation. See file COPYING. */ -#include "rgw_bucket_sync_cache.h" +#include #include +#include "rgw_bucket_sync_cache.h" + using namespace rgw::bucket_sync; -// helper function to construct rgw_bucket_shard -static rgw_bucket_shard make_key(const std::string& tenant, - const std::string& bucket, int shard) +using Shard = ShardState; +using Gen = GenState; + +namespace { +/// Create a key suitable for a given cache +/// +/// \tparam State One of `Shard` or `Gen`. +/// +/// \param[in] tenant Owning tenant +/// \param[in] bucket Bucket name +/// \param[in] shard Bucket shard, ignored if `State` is `Gen`. +/// +/// \return A key for the given bucket +template +auto make_key(const std::string& tenant, const std::string& bucket, int shard) = + delete; + +template <> +auto +make_key(const std::string& tenant, const std::string& bucket, int shard) { auto key = rgw_bucket_key{tenant, bucket}; return rgw_bucket_shard{std::move(key), shard}; } -TEST(BucketSyncCache, ReturnCachedPinned) +template <> +auto +make_key( + const std::string& tenant, + const std::string& bucket, + // Dummy parameter for overload + int) { - auto cache = Cache::create(0); - const auto key = make_key("", "1", 0); + auto key = rgw_bucket_key{tenant, bucket}; + rgw_bucket b{std::move(key)}; + return b.get_key(); +} + +/// Stick an integer into a state +/// +/// \tparam State One of `Shard` or `Gen`. +/// +/// \param[inout] state State to modify +/// \param[in] value Value to store +template +void mutate(State&, unsigned int value) = delete; + +template <> +void +mutate(ShardState& state, unsigned int value) +{ + state.counter = value; +} + +template <> +void +mutate(GenState& state, unsigned int value) +{ + state.last_future_generation_recovery = ceph::coarse_mono_time{ + ceph::timespan{value}}; +} + +/// Retrieve an integer from a state +/// +/// \note Intended only for values stored with `mutate`, anything else +/// will be truncated to the capacity of an `unsigned int`. +/// +/// \tparam State One of `Shard` or `Gen`. +/// +/// \param[in] state State to modify +/// +/// \return The integer previously stored in the state +template +unsigned int extract(const State&) = delete; + +template <> +unsigned int +extract(const Shard& state) +{ + return static_cast(state.counter); +} + +template <> +unsigned int +extract(const Gen& state) +{ + return static_cast( + state.last_future_generation_recovery.time_since_epoch().count()); +} +} // namespace + +template +void +ReturnCachedPinned() +{ + auto cache = Cache::create(0); + const auto key = make_key("", "1", 0); auto h1 = cache->get(key, std::nullopt); // pin - h1->counter = 1; + mutate(*h1, 1); auto h2 = cache->get(key, std::nullopt); - EXPECT_EQ(1, h2->counter); + EXPECT_EQ(1, extract(*h2)); } -TEST(BucketSyncCache, ReturnNewUnpinned) +TEST(BucketShardSyncCache, ReturnCachedPinned) { ReturnCachedPinned(); } + +TEST(BucketGenSyncCache, ReturnCachedPinned) { ReturnCachedPinned(); } + +template +void +ReturnNewUnpinned() { - auto cache = Cache::create(0); - const auto key = make_key("", "1", 0); - cache->get(key, std::nullopt)->counter = 1; // pin+unpin - EXPECT_EQ(0, cache->get(key, std::nullopt)->counter); + auto cache = Cache::create(0); + const auto key = make_key("", "1", 0); + mutate(*cache->get(key, std::nullopt), 1); // pin+unpin + EXPECT_EQ(0, extract(*cache->get(key, std::nullopt))); } -TEST(BucketSyncCache, DistinctTenant) +TEST(BucketShardSyncCache, ReturnNewUnpinned) { ReturnNewUnpinned(); } + +TEST(BucketGenSyncCache, ReturnNewUnpinned) { ReturnNewUnpinned(); } + +template +void +DistinctTenant() { - auto cache = Cache::create(2); - const auto key1 = make_key("a", "bucket", 0); - const auto key2 = make_key("b", "bucket", 0); + auto cache = Cache::create(2); + const auto key1 = make_key("a", "bucket", 0); + const auto key2 = make_key("b", "bucket", 0); + mutate(*cache->get(key1, std::nullopt), 1); + EXPECT_EQ(0, extract(*cache->get(key2, std::nullopt))); +} + +TEST(BucketShardSyncCache, DistinctTenant) { DistinctTenant(); } + +TEST(BucketGenSyncCache, DistinctTenant) { DistinctTenant(); } + +TEST(BucketShardSyncCache, DistinctShards) +{ + auto cache = ShardCache::create(2); + const auto key1 = make_key("", "bucket", 0); + const auto key2 = make_key("", "bucket", 1); cache->get(key1, std::nullopt)->counter = 1; EXPECT_EQ(0, cache->get(key2, std::nullopt)->counter); } -TEST(BucketSyncCache, DistinctShards) +template +void +DistinctGen() { - auto cache = Cache::create(2); - const auto key1 = make_key("", "bucket", 0); - const auto key2 = make_key("", "bucket", 1); - cache->get(key1, std::nullopt)->counter = 1; - EXPECT_EQ(0, cache->get(key2, std::nullopt)->counter); -} - -TEST(BucketSyncCache, DistinctGen) -{ - auto cache = Cache::create(2); - const auto key = make_key("", "bucket", 0); + auto cache = Cache::create(2); + const auto key = make_key("", "bucket", 0); std::optional gen1; // empty std::optional gen2 = 5; - cache->get(key, gen1)->counter = 1; - EXPECT_EQ(0, cache->get(key, gen2)->counter); + mutate(*cache->get(key, gen1), 1); + EXPECT_EQ(0, extract(*cache->get(key, gen2))); } -TEST(BucketSyncCache, DontEvictPinned) -{ - auto cache = Cache::create(0); +TEST(BucketShardSyncCache, DistinctGen) { DistinctGen(); } - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); +TEST(BucketGenSyncCache, DistinctGen) { DistinctGen(); } + +template +void +DontEvictPinned() +{ + auto cache = Cache::create(0); + + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); auto h1 = cache->get(key1, std::nullopt); EXPECT_EQ(key1, h1->key.first); @@ -85,46 +196,64 @@ TEST(BucketSyncCache, DontEvictPinned) EXPECT_EQ(key1, h1->key.first); // h1 unchanged } -TEST(BucketSyncCache, HandleLifetime) -{ - const auto key = make_key("", "1", 0); +TEST(BucketShardSyncCache, DontEvictPinned) { DontEvictPinned(); } - Handle h; // test that handles keep the cache referenced +TEST(BucketGenSyncCache, DontEvictPinned) { DontEvictPinned(); } + +template +void +HandleLifetime() +{ + const auto key = make_key("", "1", 0); + + Handle h; // test that handles keep the cache referenced { - auto cache = Cache::create(0); + auto cache = Cache::create(0); h = cache->get(key, std::nullopt); } EXPECT_EQ(key, h->key.first); } -TEST(BucketSyncCache, TargetSize) -{ - auto cache = Cache::create(2); +TEST(BucketShardSyncCache, HandleLifetime) { HandleLifetime(); } - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); - const auto key3 = make_key("", "3", 0); +TEST(BucketGenSyncCache, HandleLifetime) { HandleLifetime(); } + +template +void +TargetSize() +{ + auto cache = Cache::create(2); + + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); + const auto key3 = make_key("", "3", 0); // fill cache up to target_size=2 - cache->get(key1, std::nullopt)->counter = 1; - cache->get(key2, std::nullopt)->counter = 2; + mutate(*cache->get(key1, std::nullopt), 1); + mutate(*cache->get(key2, std::nullopt), 2); // test that each unpinned entry is still cached - EXPECT_EQ(1, cache->get(key1, std::nullopt)->counter); - EXPECT_EQ(2, cache->get(key2, std::nullopt)->counter); + EXPECT_EQ(1, extract(*cache->get(key1, std::nullopt))); + EXPECT_EQ(2, extract(*cache->get(key2, std::nullopt))); // overflow the cache and recycle key1 - cache->get(key3, std::nullopt)->counter = 3; + mutate(*cache->get(key3, std::nullopt), 3); // test that the oldest entry was recycled - EXPECT_EQ(0, cache->get(key1, std::nullopt)->counter); + EXPECT_EQ(0, extract(*cache->get(key1, std::nullopt))); } -TEST(BucketSyncCache, HandleMoveAssignEmpty) +TEST(BucketShardSyncCache, TargetSize) { TargetSize(); } + +TEST(BucketGenSyncCache, TargetSize) { TargetSize(); } + +template +void +HandleMoveAssignEmpty() { - auto cache = Cache::create(0); + auto cache = Cache::create(0); - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); - Handle j1; + Handle j1; { auto h1 = cache->get(key1, std::nullopt); j1 = std::move(h1); // assign over empty handle @@ -134,32 +263,56 @@ TEST(BucketSyncCache, HandleMoveAssignEmpty) EXPECT_EQ(key1, j1->key.first); // j1 stays pinned } -TEST(BucketSyncCache, HandleMoveAssignExisting) +TEST(BucketShardSyncCache, HandleMoveAssignEmpty) { - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); + HandleMoveAssignEmpty(); +} - Handle h1; +TEST(BucketGenSyncCache, HandleMoveAssignEmpty) +{ + HandleMoveAssignEmpty(); +} + +template +void +HandleMoveAssignExisting() +{ + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); + + Handle h1; { - auto cache1 = Cache::create(0); + auto cache1 = Cache::create(0); h1 = cache1->get(key1, std::nullopt); - } // j1 has the last ref to cache1 + } // h1 has the last ref to cache1 { - auto cache2 = Cache::create(0); + auto cache2 = Cache::create(0); auto h2 = cache2->get(key2, std::nullopt); h1 = std::move(h2); // assign over existing handle } EXPECT_EQ(key2, h1->key.first); } -TEST(BucketSyncCache, HandleCopyAssignEmpty) +TEST(BucketShardSyncCache, HandleMoveAssignExisting) { - auto cache = Cache::create(0); + HandleMoveAssignExisting(); +} - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); +TEST(BucketGenSyncCache, HandleMoveAssignExisting) +{ + HandleMoveAssignExisting(); +} - Handle j1; +template +void +HandleCopyAssignEmpty() +{ + auto cache = Cache::create(0); + + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); + + Handle j1; { auto h1 = cache->get(key1, std::nullopt); j1 = h1; // assign over empty handle @@ -169,21 +322,43 @@ TEST(BucketSyncCache, HandleCopyAssignEmpty) EXPECT_EQ(key1, j1->key.first); // j1 stays pinned } -TEST(BucketSyncCache, HandleCopyAssignExisting) +TEST(BucketShardSyncCache, HandleCopyAssignEmpty) { - const auto key1 = make_key("", "1", 0); - const auto key2 = make_key("", "2", 0); + HandleCopyAssignEmpty(); +} - Handle h1; +TEST(BucketGenSyncCache, HandleCopyAssignEmpty) +{ + HandleCopyAssignEmpty(); +} + +template +void +HandleCopyAssignExisting() +{ + const auto key1 = make_key("", "1", 0); + const auto key2 = make_key("", "2", 0); + + Handle h1; { - auto cache1 = Cache::create(0); + auto cache1 = Cache::create(0); h1 = cache1->get(key1, std::nullopt); - } // j1 has the last ref to cache1 + } // h1 has the last ref to cache1 { - auto cache2 = Cache::create(0); + auto cache2 = Cache::create(0); auto h2 = cache2->get(key2, std::nullopt); h1 = h2; // assign over existing handle EXPECT_EQ(&*h1, &*h2); } EXPECT_EQ(key2, h1->key.first); } + +TEST(BucketShardSyncCache, HandleCopyAssignExisting) +{ + HandleCopyAssignExisting(); +} + +TEST(BucketGenSyncCache, HandleCopyAssignExisting) +{ + HandleCopyAssignExisting(); +} From e28e7544b73b32fe3fe4f395f6ee3af342064b89 Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Thu, 2 Apr 2026 15:43:27 -0400 Subject: [PATCH 051/596] rgw/multi: Reliably recover from future generation sync Since we can't map shards between generations, if we get a sync entry for a future shard, synthesize an entry on the current generation for all shards. We use the bucket cache to keep track of when we've done this so we don't give ourselves unbounded work when we get bursts of future generation shards. Fixes: https://tracker.ceph.com/issues/75786 Signed-off-by: Adam C. Emerson --- src/rgw/driver/rados/rgw_data_sync.cc | 145 +++++++++++++++++--------- 1 file changed, 97 insertions(+), 48 deletions(-) diff --git a/src/rgw/driver/rados/rgw_data_sync.cc b/src/rgw/driver/rados/rgw_data_sync.cc index abe1e611ada..058a30cac54 100644 --- a/src/rgw/driver/rados/rgw_data_sync.cc +++ b/src/rgw/driver/rados/rgw_data_sync.cc @@ -1412,13 +1412,16 @@ class RGWRunBucketSourcesSyncCR : public RGWCoroutine { rgw_bucket_index_marker_info marker_info; BucketIndexShardsManager marker_mgr; + rgw::bucket_sync::GenHandle gen_state; + public: RGWRunBucketSourcesSyncCR(RGWDataSyncCtx *_sc, boost::intrusive_ptr lease_cr, const rgw_bucket_shard& source_bs, const RGWSyncTraceNodeRef& _tn_parent, std::optional gen, - ceph::real_time* progress); + ceph::real_time* progress, + rgw::bucket_sync::GenHandle gen_state); int operate(const DoutPrefixProvider *dpp) override; }; @@ -1427,6 +1430,7 @@ class RGWDataSyncSingleEntryCR : public RGWCoroutine { RGWDataSyncCtx *sc; RGWDataSyncEnv *sync_env; rgw::bucket_sync::ShardHandle state; // cached bucket-shard state + rgw::bucket_sync::GenHandle gen_state; // cached bucket state rgw_data_sync_obligation obligation; // input obligation std::optional complete; // obligation to complete uint32_t obligation_counter = 0; @@ -1439,13 +1443,14 @@ class RGWDataSyncSingleEntryCR : public RGWCoroutine { int sync_status = 0; public: RGWDataSyncSingleEntryCR(RGWDataSyncCtx *_sc, rgw::bucket_sync::ShardHandle state, + rgw::bucket_sync::GenHandle gen_state, rgw_data_sync_obligation _obligation, RGWDataSyncShardMarkerTrack *_marker_tracker, const rgw_raw_obj& error_repo, boost::intrusive_ptr lease_cr, const RGWSyncTraceNodeRef& _tn_parent) : RGWCoroutine(_sc->cct), sc(_sc), sync_env(_sc->env), - state(std::move(state)), obligation(std::move(_obligation)), + state(std::move(state)), gen_state(std::move(gen_state)), obligation(std::move(_obligation)), marker_tracker(_marker_tracker), error_repo(error_repo), lease_cr(std::move(lease_cr)) { set_description() << "data sync single entry (source_zone=" << sc->source_zone << ") " << obligation; @@ -1487,7 +1492,7 @@ public: yield call(new RGWRunBucketSourcesSyncCR(sc, lease_cr, state->key.first, tn, state->obligation->gen, - &progress)); + &progress, gen_state)); if (retcode < 0) { break; } @@ -1642,13 +1647,15 @@ RGWCoroutine* data_sync_single_entry(RGWDataSyncCtx *sc, const rgw_bucket_shard& ceph::real_time timestamp, boost::intrusive_ptr lease_cr, boost::intrusive_ptr bucket_shard_cache, + boost::intrusive_ptr bucket_gen_cache, RGWDataSyncShardMarkerTrack* marker_tracker, rgw_raw_obj error_repo, RGWSyncTraceNodeRef& tn, bool retry) { auto state = bucket_shard_cache->get(src, gen); + auto gen_state = bucket_gen_cache->get(src.bucket.get_key(), gen); auto obligation = rgw_data_sync_obligation{src, gen, marker, timestamp, retry}; - return new RGWDataSyncSingleEntryCR(sc, std::move(state), std::move(obligation), + return new RGWDataSyncSingleEntryCR(sc, std::move(state), std::move(gen_state), std::move(obligation), &*marker_tracker, error_repo, lease_cr.get(), tn); } @@ -1675,6 +1682,7 @@ class RGWDataFullSyncSingleEntryCR : public RGWCoroutine { ceph::real_time timestamp; boost::intrusive_ptr lease_cr; boost::intrusive_ptr bucket_shard_cache; + boost::intrusive_ptr bucket_gen_cache; RGWDataSyncShardMarkerTrack* marker_tracker; RGWSyncTraceNodeRef tn; rgw_bucket_index_marker_info remote_info; @@ -1690,11 +1698,12 @@ public: const std::string& _key, const rgw_data_sync_status& _sync_status, const rgw_raw_obj& _error_repo, ceph::real_time _timestamp, boost::intrusive_ptr _lease_cr, boost::intrusive_ptr _bucket_shard_cache, + boost::intrusive_ptr _bucket_gen_cache, RGWDataSyncShardMarkerTrack* _marker_tracker, RGWSyncTraceNodeRef& _tn) : RGWCoroutine(_sc->cct), sc(_sc), sync_env(_sc->env), pool(_pool), source_bs(_source_bs), key(_key), sync_status(_sync_status), error_repo(_error_repo), timestamp(_timestamp), lease_cr(std::move(_lease_cr)), - bucket_shard_cache(_bucket_shard_cache), marker_tracker(_marker_tracker), tn(_tn) { + bucket_shard_cache(_bucket_shard_cache), bucket_gen_cache(_bucket_gen_cache), marker_tracker(_marker_tracker), tn(_tn) { error_inject = (sync_env->cct->_conf->rgw_sync_data_full_inject_err_probability > 0); } @@ -1740,7 +1749,7 @@ public: timestamp), sc->lcc.adj_concurrency(cct->_conf->rgw_data_sync_spawn_window), std::nullopt); } else { shard_cr = data_sync_single_entry(sc, source_bs, each->gen, key, timestamp, - lease_cr, bucket_shard_cache, nullptr, error_repo, tn, false); + lease_cr, bucket_shard_cache, bucket_gen_cache, nullptr, error_repo, tn, false); tn->log(10, SSTR("full sync: syncing shard_id " << sid << " of gen " << each->gen)); if (first_shard) { first_shard = false; @@ -1792,6 +1801,7 @@ protected: const rgw_data_sync_status& sync_status; RGWObjVersionTracker& objv; boost::intrusive_ptr bucket_shard_cache; + boost::intrusive_ptr bucket_gen_cache; std::optional marker_tracker; RGWRadosGetOmapValsCR::ResultPtr omapvals; @@ -1818,12 +1828,13 @@ protected: boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, - const boost::intrusive_ptr& bucket_shard_cache) + const boost::intrusive_ptr& bucket_shard_cache, + const boost::intrusive_ptr& bucket_gen_cache) : RGWCoroutine(_sc->cct), sc(_sc), pool(pool), shard_id(shard_id), sync_marker(sync_marker), tn(tn), status_oid(status_oid), error_repo(error_repo), lease_cr(std::move(lease_cr)), sync_status(sync_status), objv(objv), - bucket_shard_cache(bucket_shard_cache) {} + bucket_shard_cache(bucket_shard_cache), bucket_gen_cache(bucket_gen_cache) {} }; class RGWDataFullSyncShardCR : public RGWDataBaseSyncShardCR { @@ -1846,10 +1857,11 @@ public: const string& status_oid, const rgw_raw_obj& error_repo, boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, - const boost::intrusive_ptr& bucket_shard_cache) + const boost::intrusive_ptr& bucket_shard_cache, + const boost::intrusive_ptr& bucket_gen_cache) : RGWDataBaseSyncShardCR(sc, pool, shard_id, sync_marker, tn, status_oid, error_repo, std::move(lease_cr), - sync_status, objv, bucket_shard_cache) {} + sync_status, objv, bucket_shard_cache, bucket_gen_cache) {} int operate(const DoutPrefixProvider *dpp) override { reenter(this) { @@ -1905,7 +1917,7 @@ public: yield_spawn_window(new RGWDataFullSyncSingleEntryCR( sc, pool, source_bs, iter->first, sync_status, error_repo, entry_timestamp, lease_cr, - bucket_shard_cache, &*marker_tracker, tn), + bucket_shard_cache, bucket_gen_cache, &*marker_tracker, tn), sc->lcc.adj_concurrency(cct->_conf->rgw_data_sync_spawn_window), std::nullopt); } @@ -2000,11 +2012,12 @@ public: boost::intrusive_ptr lease_cr, const rgw_data_sync_status& sync_status, RGWObjVersionTracker& objv, const boost::intrusive_ptr& bucket_shard_cache, + const boost::intrusive_ptr& bucket_gen_cache, ceph::mutex& inc_lock, bc::flat_set& modified_shards) : RGWDataBaseSyncShardCR(sc, pool, shard_id, sync_marker, tn, status_oid, error_repo, std::move(lease_cr), - sync_status, objv, bucket_shard_cache), + sync_status, objv, bucket_shard_cache, bucket_gen_cache), inc_lock(inc_lock), modified_shards(modified_shards) {} int operate(const DoutPrefixProvider *dpp) override { @@ -2052,7 +2065,7 @@ public: << modified_iter->key)); spawn(data_sync_single_entry(sc, source_bs, modified_iter->gen, {}, ceph::real_time{}, lease_cr, - bucket_shard_cache, &*marker_tracker, + bucket_shard_cache, bucket_gen_cache, &*marker_tracker, error_repo, tn, false), false); } @@ -2098,7 +2111,7 @@ public: << " timestamp=" << entry_timestamp)); spawn(data_sync_single_entry(sc, source_bs, gen, "", entry_timestamp, lease_cr, - bucket_shard_cache, &*marker_tracker, + bucket_shard_cache, bucket_gen_cache, &*marker_tracker, error_repo, tn, true), false); } } @@ -2152,7 +2165,7 @@ public: } else { tn->log(1, SSTR("incremental sync on " << log_iter->entry.key << "shard: " << shard_id << "on gen " << log_iter->entry.gen)); yield_spawn_window(data_sync_single_entry(sc, source_bs, log_iter->entry.gen, log_iter->log_id, - log_iter->log_timestamp, lease_cr,bucket_shard_cache, + log_iter->log_timestamp, lease_cr,bucket_shard_cache, bucket_gen_cache, &*marker_tracker, error_repo, tn, false), sc->lcc.adj_concurrency(cct->_conf->rgw_data_sync_spawn_window), [&](uint64_t stack_id, int ret) { @@ -2221,8 +2234,10 @@ class RGWDataSyncShardCR : public RGWCoroutine { // target number of entries to cache before recycling idle ones static constexpr size_t target_cache_size = 256; - boost::intrusive_ptr bucket_shard_cache { - rgw::bucket_sync::ShardCache::create(target_cache_size) }; + boost::intrusive_ptr bucket_shard_cache{ + rgw::bucket_sync::ShardCache::create(target_cache_size)}; + boost::intrusive_ptr bucket_gen_cache{ + rgw::bucket_sync::GenCache::create(target_cache_size)}; boost::intrusive_ptr lease_cr; boost::intrusive_ptr lease_stack; @@ -2290,7 +2305,7 @@ public: sync_marker, tn, status_oid, error_repo, lease_cr, sync_status, - objv, bucket_shard_cache)); + objv, bucket_shard_cache, bucket_gen_cache)); if (retcode < 0) { if (retcode != -EBUSY) { tn->log(10, SSTR("full sync failed (retcode=" << retcode << ")")); @@ -2304,7 +2319,7 @@ public: sync_marker, tn, status_oid, error_repo, lease_cr, sync_status, - objv, bucket_shard_cache, + objv, bucket_shard_cache, bucket_gen_cache, inc_lock, modified_shards)); if (retcode < 0) { if (retcode != -EBUSY) { @@ -5326,6 +5341,7 @@ static RGWCoroutine* sync_bucket_shard_cr(RGWDataSyncCtx* sc, std::optional gen, const RGWSyncTraceNodeRef& tn, ceph::real_time* progress, + ceph::coarse_mono_time& last_future_generation_recovery, bool no_lease = false); RGWRunBucketSourcesSyncCR::RGWRunBucketSourcesSyncCR(RGWDataSyncCtx *_sc, @@ -5333,14 +5349,16 @@ RGWRunBucketSourcesSyncCR::RGWRunBucketSourcesSyncCR(RGWDataSyncCtx *_sc, const rgw_bucket_shard& source_bs, const RGWSyncTraceNodeRef& _tn_parent, std::optional gen, - ceph::real_time* progress) + ceph::real_time* progress, + rgw::bucket_sync::GenHandle gen_state) : RGWCoroutine(_sc->env->cct), sc(_sc), sync_env(_sc->env), lease_cr(std::move(lease_cr)), tn(sync_env->sync_tracer->add_node( _tn_parent, "bucket_sync_sources", SSTR( "source=" << source_bs << ":source_zone=" << sc->source_zone))), progress(progress), - gen(gen) + gen(gen), + gen_state(std::move(gen_state)) { sync_pair.source_bs = source_bs; } @@ -5375,7 +5393,7 @@ int RGWRunBucketSourcesSyncCR::operate(const DoutPrefixProvider *dpp) yield_spawn_window(sync_bucket_shard_cr(sc, lease_cr, sync_pair, gen, tn, &*cur_shard_progress, - false), + gen_state->last_future_generation_recovery, false), sc->lcc.adj_concurrency(cct->_conf->rgw_bucket_sync_spawn_window), [&](uint64_t stack_id, int ret) { if (ret < 0) { @@ -5774,16 +5792,21 @@ class RGWSyncBucketCR : public RGWCoroutine { rgw_bucket_shard source_bs; rgw_pool pool; uint64_t current_gen = 0; + // In general operation, a reference to a pinned entry in `bucket_gen_cache` + ceph::coarse_mono_time& last_future_generation_recovery; RGWSyncTraceNodeRef tn; + static constexpr std::chrono::seconds throttle_future_recovery = std::chrono::hours(1); + public: RGWSyncBucketCR(RGWDataSyncCtx *_sc, boost::intrusive_ptr lease_cr, - const rgw_bucket_sync_pair_info& _sync_pair, + const rgw_bucket_sync_pair_info &_sync_pair, std::optional gen, const RGWSyncTraceNodeRef& _tn_parent, ceph::real_time* progress, + ceph::coarse_mono_time& last_future_generation_recovery, bool no_lease = false) : RGWCoroutine(_sc->cct), sc(_sc), env(_sc->env), data_lease_cr(std::move(lease_cr)), sync_pair(_sync_pair), @@ -5793,7 +5816,7 @@ public: RGWBucketPipeSyncStatusManager::full_status_oid(sc->source_zone, sync_pair.source_bs.bucket, sync_pair.dest_bucket)), - no_lease(no_lease), + no_lease(no_lease), last_future_generation_recovery(last_future_generation_recovery), tn(env->sync_tracer->add_node(_tn_parent, "bucket", SSTR(bucket_str{_sync_pair.dest_bucket} << "<-" << bucket_shard_str{_sync_pair.source_bs} ))) { } @@ -5807,10 +5830,11 @@ static RGWCoroutine* sync_bucket_shard_cr(RGWDataSyncCtx* sc, std::optional gen, const RGWSyncTraceNodeRef& tn, ceph::real_time* progress, + ceph::coarse_mono_time& last_future_generation_recovery, bool no_lease) { return new RGWSyncBucketCR(sc, std::move(lease), sync_pair, - gen, tn, progress, no_lease); + gen, tn, progress, last_future_generation_recovery, no_lease); } #define RELEASE_LOCK(cr) \ @@ -6009,29 +6033,46 @@ int RGWSyncBucketCR::operate(const DoutPrefixProvider *dpp) if (*gen > current_gen) { /* In case the data log entry is missing for previous gen, it may * not be marked complete and the sync can get stuck. To avoid it, - * may be we can add this (shardid, gen) to error repo to force - * sync and mark that shard as completed. + * may be we can add an entry for every shard in the previous generation. */ pool = sc->env->svc->zone->get_zone_params().log_pool; - if ((static_cast(source_bs.shard_id) < bucket_status.shards_done_with_gen.size()) && - !bucket_status.shards_done_with_gen[source_bs.shard_id]) { + if ((ceph::coarse_mono_clock::now() - last_future_generation_recovery) > throttle_future_recovery) { + last_future_generation_recovery = ceph::coarse_mono_clock::now(); // use the error repo and sync status timestamp from the datalog shard corresponding to source_bs - error_repo = datalog_oid_for_error_repo(sc, sc->env->driver, - pool, source_bs); - yield call(rgw::error_repo::write_cr(sc->env->driver->getRados()->get_rados_handle(), error_repo, - rgw::error_repo::encode_key(source_bs, current_gen), - ceph::real_clock::zero())); - if (retcode < 0) { - tn->log(0, SSTR("ERROR: failed to log prev gen entry (bucket=" << source_bs.bucket << ", shard_id=" << source_bs.shard_id << ", gen=" << current_gen << " in error repo: retcode=" << retcode)); - } else { - tn->log(20, SSTR("logged prev gen entry (bucket=" << source_bs.bucket << ", shard_id=" << source_bs.shard_id << ", gen=" << current_gen << " in error repo: retcode=" << retcode)); + for (source_bs.shard_id = 0; + source_bs.shard_id < std::ssize(bucket_status.shards_done_with_gen); + ++source_bs.shard_id) { + error_repo = datalog_oid_for_error_repo(sc, sc->env->driver, + pool, source_bs); + tn->log(10, SSTR("writing shard_id " << source_bs.shard_id << " of gen " << current_gen << " to error repo for retry")); + yield_spawn_window( + rgw::error_repo::write_cr( + sc->env->driver->getRados()->get_rados_handle(), + error_repo, + rgw::error_repo::encode_key(source_bs, current_gen), + ceph::real_clock::zero()), + sc->lcc.adj_concurrency( + cct->_conf->rgw_data_sync_spawn_window), + [&](uint64_t stack_id, int ret) { + if (ret < 0) { + retcode = ret; + } + return 0; + }); } + drain_all_cb([&](uint64_t stack_id, int ret) { + if (ret < 0) { + tn->log(10, + SSTR("writing to error repo returned error: " << ret)); + } + return ret; + }); } - retcode = -EAGAIN; - tn->log(10, SSTR("ERROR: requested sync of future generation " - << *gen << " > " << current_gen - << ", returning " << retcode << " for later retry")); - return set_cr_error(retcode); + retcode = -EAGAIN; + tn->log(10, SSTR("ERROR: requested sync of future generation " + << *gen << " > " << current_gen + << ", returning " << retcode << " for later retry")); + return set_cr_error(retcode); } else if (*gen < current_gen) { tn->log(10, SSTR("WARNING: requested sync of past generation " << *gen << " < " << current_gen @@ -6274,11 +6315,14 @@ class ShardCR : public RGWCoroutine { ceph::real_time prev_progress; ceph::real_time progress; -public: + ceph::coarse_mono_time& last_future_generation_recovery; - ShardCR(RGWDataSyncCtx& sc, const rgw_bucket_sync_pair_info& pair, - const uint64_t gen) - : RGWCoroutine(sc.cct), sc(sc), pair(pair), gen(gen) {} +public: + ShardCR(RGWDataSyncCtx &sc, const rgw_bucket_sync_pair_info &pair, + const uint64_t gen, + ceph::coarse_mono_time &last_future_generation_recovery) + : RGWCoroutine(sc.cct), sc(sc), pair(pair), gen(gen), + last_future_generation_recovery( last_future_generation_recovery) {} int operate(const DoutPrefixProvider *dpp) override { reenter(this) { @@ -6295,6 +6339,7 @@ public: yield call(sync_bucket_shard_cr(&sc, nullptr, pair, gen, sc.env->sync_tracer->root_node, &progress, + last_future_generation_recovery, true /* no_lease: bucket sync run skips lock acquisition so it is never blocked by a background sync process*/)); @@ -6341,6 +6386,10 @@ class GenCR : public RGWShardCollectCR { std::vector pairs; decltype(pairs)::const_iterator iter; + // We do a manual sync when we're told to do a manual sync. No need + // for a cache. + ceph::coarse_mono_time last_future_generation_recovery = ceph::coarse_mono_clock::zero(); + public: GenCR(RGWDataSyncCtx& sc, const rgw_bucket& source, const rgw_bucket& dest, const uint64_t gen, const uint64_t shards, @@ -6363,7 +6412,7 @@ public: if (iter == pairs.cend()) { return false; } - spawn(new ShardCR(sc, *iter, gen), false); + spawn(new ShardCR(sc, *iter, gen, last_future_generation_recovery), false); ++iter; return true; } From 066b33e805565cce462484ff564074b89419c4b5 Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Wed, 22 Apr 2026 00:37:32 -0400 Subject: [PATCH 052/596] build: Add `cls_timeindex_types` to `cls_timeindex` objclass `cls_timeindex_entry` defines `encode()` and `decode()` inline in `cls_timeindex_types.h`, but `dump()` and `generate_test_instances()` are defined out of line in `cls_timeindex_types.cc`. That `.cc` was compiled only into `cls_timeindex_client`, not into the `cls_timeindex objclass` shared library. At `-O2` the optimizer inlines and dead-code-eliminates these unused `dump()` paths, so `cls_timeindex.so` ends up with no reference to the out-of-line symbols and loads fine. At `-O0` nothing is inlined or eliminated: the header-inline `dump()` is emitted and leaves an undefined reference to `cls_timeindex_entry::dump()` in `cls_timeindex.so`, so `dlopen()` of the objclass fails at load time. Signed-off-by: Adam C. Emerson --- src/cls/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cls/CMakeLists.txt b/src/cls/CMakeLists.txt index ad5c71c51b6..cfcc2f86790 100644 --- a/src/cls/CMakeLists.txt +++ b/src/cls/CMakeLists.txt @@ -147,7 +147,7 @@ add_library(cls_log_client STATIC ${cls_log_client_srcs}) # cls_timeindex -set(cls_timeindex_srcs timeindex/cls_timeindex.cc) +set(cls_timeindex_srcs timeindex/cls_timeindex.cc timeindex/cls_timeindex_types.cc) add_library(cls_timeindex SHARED ${cls_timeindex_srcs}) set_target_properties(cls_timeindex PROPERTIES VERSION "1.0.0" From 43a62944af86377a1e2143e3139087cb41ff4365 Mon Sep 17 00:00:00 2001 From: Kobi Ginon Date: Sat, 2 May 2026 16:14:48 +0300 Subject: [PATCH 053/596] cephadm: list-networks opt-in loopback and BGP route supplements Fixes: https://tracker.ceph.com/issues/62195 Extend cephadm list-networks (and the cephadm mgr module options that pass flags through to it) so hosts can expose loopback and BGP-derived routes when operators need them, while keeping the default conservative for orchestrator network discovery. - Add cephadm flags --allow-lo-routes and --allow-bgp-routes (after the list-networks subcommand). Wire mgr options allow_lo_routes and allow_bgp_routes to the remote cephadm invocation. - IPv4: build from ``ip route ls`` (scope link + src). When allow_lo_routes is true, merge extra lines from the same output via _parse_ipv4_lo_route (lo /32 without src, host-scoped routes including dummy-style /32 from 76229). Always omit prefixes wholly inside 127.0.0.0/8. When allow_lo_routes is false, skip any route whose device is lo at parse time. When allow_bgp_routes is true, merge ``ip route ls proto bgp`` (ECMP nexthops and lo BGP /32), again respecting allow_lo_routes for lo. - IPv6: build from ``ip -6 route ls`` and ``ip -6 addr ls``; omit ::1/128. When allow_lo_routes is false, skip routes on lo (including global /128 on lo). When allow_bgp_routes is true, merge ``ip -6 route ls proto bgp`` in the same way as IPv4, including lo BGP /128 when allow_lo_routes is true. Signed-off-by: Kobi Ginon --- src/cephadm/cephadm.py | 12 + src/cephadm/cephadmlib/host_facts.py | 292 ++++++++++++++-- src/cephadm/tests/test_networks.py | 50 ++- src/cephadm/tests/test_networks_lo_routes.py | 349 +++++++++++++++++++ src/pybind/mgr/cephadm/module.py | 19 + src/pybind/mgr/cephadm/serve.py | 13 +- 6 files changed, 706 insertions(+), 29 deletions(-) create mode 100644 src/cephadm/tests/test_networks_lo_routes.py diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 626ceb54d54..b3133088961 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -5019,6 +5019,18 @@ def _get_parser(): parser_list_networks = subparsers.add_parser( 'list-networks', help='list IP networks') + parser_list_networks.add_argument( + '--allow-lo-routes', + action='store_true', + default=False, + help='Reserved for future filtering of loopback-related routes', + ) + parser_list_networks.add_argument( + '--allow-bgp-routes', + action='store_true', + default=False, + help='Reserved for future filtering of BGP-derived routes', + ) parser_list_networks.set_defaults(func=command_list_networks) parser_list_rdma = subparsers.add_parser( diff --git a/src/cephadm/cephadmlib/host_facts.py b/src/cephadm/cephadmlib/host_facts.py index ec85430d2aa..c4acd4a6b69 100644 --- a/src/cephadm/cephadmlib/host_facts.py +++ b/src/cephadm/cephadmlib/host_facts.py @@ -849,14 +849,18 @@ class HostFacts: def list_networks(ctx): # type: (CephadmContext) -> Dict[str,Dict[str, Set[str]]] - # sadly, 18.04's iproute2 4.15.0-2ubun doesn't support the -j flag, - # so we'll need to use a regex to parse 'ip' command output. - # - # out, _, _ = call_throws(['ip', '-j', 'route', 'ls']) - # j = json.loads(out) - # for x in j: - res = _list_ipv4_networks(ctx) - res.update(_list_ipv6_networks(ctx)) + # Main route tables use text ``ip route ls`` (regex parsers). BGP supplements + # use ``ip -j route ls proto bgp`` on supported platforms (iproute2 >= 4.18). + allow_lo = getattr(ctx, 'allow_lo_routes', False) + allow_bgp = getattr(ctx, 'allow_bgp_routes', False) + res = _list_ipv4_networks( + ctx, allow_lo_routes=allow_lo, allow_bgp_routes=allow_bgp + ) + res.update( + _list_ipv6_networks( + ctx, allow_lo_routes=allow_lo, allow_bgp_routes=allow_bgp + ) + ) return res @@ -905,21 +909,50 @@ def list_rdma(ctx: CephadmContext) -> List[Dict[str, str]]: return result -def _list_ipv4_networks( - ctx: CephadmContext, -) -> Dict[str, Dict[str, Set[str]]]: - execstr: Optional[str] = find_executable('ip') - if not execstr: - raise FileNotFoundError("unable to find 'ip' command") - out, _, _ = call_throws( - ctx, - [execstr, 'route', 'ls'], - verbosity=CallVerbosity.QUIET_UNLESS_ERROR, +_IPV4_LOOPBACK_SPACE = ipaddress.ip_network('127.0.0.0/8') +_IPV6_LOOPBACK_HOST = ipaddress.ip_network('::1/128') + + +def _is_ipv4_loopback(net_s: str) -> bool: + """True if *net_s* is an IPv4 prefix wholly within host loopback 127.0.0.0/8.""" + try: + n = ipaddress.ip_network(net_s, strict=False) + except ValueError: + return False + return ( + n.version == 4 + and n.network_address in _IPV4_LOOPBACK_SPACE + and n.broadcast_address in _IPV4_LOOPBACK_SPACE ) - return _parse_ipv4_route(out) -def _parse_ipv4_route(out: str) -> Dict[str, Dict[str, Set[str]]]: +def _is_ipv6_loopback(net_s: str) -> bool: + """True if *net_s* is exactly the host loopback prefix ::1/128.""" + try: + n = ipaddress.ip_network(net_s, strict=False) + except ValueError: + return False + if n.version != 6: + return False + return n == _IPV6_LOOPBACK_HOST + + +def _merge_ipv4_network_dicts( + dst: Dict[str, Dict[str, Set[str]]], add: Dict[str, Dict[str, Set[str]]] +) -> None: + """Merge *add* into *dst* (nested sets).""" + for net, ifaces in add.items(): + if net not in dst: + dst[net] = {} + for iface, ips in ifaces.items(): + if iface not in dst[net]: + dst[net][iface] = set() + dst[net][iface].update(ips) + + +def _parse_ipv4_route( + out: str, allow_lo_routes: bool = False +) -> Dict[str, Dict[str, Set[str]]]: r = {} # type: Dict[str, Dict[str, Set[str]]] p = re.compile( r'^(\S+) (?:via \S+)? ?dev (\S+) (.*)scope link (.*)src (\S+)' @@ -931,8 +964,13 @@ def _parse_ipv4_route(out: str) -> Dict[str, Dict[str, Set[str]]]: net = m[0][0] if '/' not in net: # aggregate /32 mask for single host sub-networks net += '/32' - iface = m[0][1] + # Strip iproute2 ``@`` suffix (e.g. ``brx.0@eno1`` → ``brx.0``). + iface = m[0][1].split('@')[0] + if iface == 'lo' and not allow_lo_routes: + continue ip = m[0][4] + if _is_ipv4_loopback(net): + continue if net not in r: r[net] = {} if iface not in r[net]: @@ -941,9 +979,199 @@ def _parse_ipv4_route(out: str) -> Dict[str, Dict[str, Set[str]]]: return r +def _parse_ipv4_lo_route(out: str) -> Dict[str, Dict[str, Set[str]]]: + """``lo`` /32 without ``src``, and ``scope host`` + ``src`` (e.g. dummy /32).""" + r = {} # type: Dict[str, Dict[str, Set[str]]] + p_lo_no_src = re.compile( + r'^(\S+) (?:via \S+)? ?dev (lo(?:@\S+)?) (.*)scope (?:link|host)\s*(?!src\b).*$' + ) + p_host_src = re.compile( + r'^(\S+) (?:via \S+)? ?dev (\S+) (.*)scope host (.*)src (\S+)' + ) + + def put(net: str, iface: str, ip: str) -> None: + if _is_ipv4_loopback(net): + return + if net not in r: + r[net] = {} + if iface not in r[net]: + r[net][iface] = set() + r[net][iface].add(ip) + + for line in out.splitlines(): + m = p_lo_no_src.findall(line) + if not m: + continue + net = m[0][0] + if '/' not in net: + net += '/32' + iface = m[0][1].split('@')[0] + try: + n = ipaddress.ip_network(net, strict=False) + except ValueError: + continue + if n.prefixlen != 32: + continue + put(net, iface, str(n.network_address)) + + for line in out.splitlines(): + m = p_host_src.findall(line) + if not m: + continue + net = m[0][0] + if '/' not in net: + net += '/32' + iface = m[0][1].split('@')[0] + ip = m[0][4] + put(net, iface, ip) + + return r + + +def _route_iface_name(dev: str) -> str: + """Strip iproute2 ``child@parent`` interface suffix.""" + return dev.split('@')[0] + + +def _route_dst_to_network(dst: str, version: int) -> str: + if '/' in dst: + return dst + return f'{dst}/{"32" if version == 4 else "128"}' + + +def _parse_bgp_routes_json( + out: str, + version: int, + allow_lo_routes: bool = False, +) -> Dict[str, Dict[str, Set[str]]]: + """Parse JSON from ``ip -j route ls proto bgp`` (or ``ip -6 -j route ls proto bgp``).""" + r: Dict[str, Dict[str, Set[str]]] = {} + try: + routes = json.loads(out) + except (json.JSONDecodeError, TypeError): + logger.debug('failed to parse ip -j route output as JSON') + return r + if not isinstance(routes, list): + return r + + is_v4 = version == 4 + host_plen = 32 if is_v4 else 128 + + def put(net: str, iface: str, ip: str) -> None: + if _is_ipv4_loopback(net) if is_v4 else _is_ipv6_loopback(net): + return + if net not in r: + r[net] = {} + if iface not in r[net]: + r[net][iface] = set() + r[net][iface].add(ip) + + for route in routes: + if not isinstance(route, dict): + continue + dst = route.get('dst') + if not dst: + continue + + net = _route_dst_to_network(dst, version) + nexthops = route.get('nexthops') + prefsrc = route.get('prefsrc') + + if nexthops and prefsrc: + for nh in nexthops: + if not isinstance(nh, dict): + continue + dev = nh.get('dev') + if not dev: + continue + iface = _route_iface_name(dev) + if iface == 'lo' and not allow_lo_routes: + continue + put(net, iface, prefsrc) + continue + + dev = route.get('dev', '') + iface = _route_iface_name(dev) if dev else '' + + if iface == 'lo' and not prefsrc: + if not allow_lo_routes: + continue + try: + n = ipaddress.ip_network(net, strict=False) + except ValueError: + continue + if n.prefixlen != host_plen: + continue + put(net, 'lo', str(n.network_address)) + continue + + if prefsrc and iface: + if iface == 'lo' and not allow_lo_routes: + continue + put(net, iface, prefsrc) + + return r + + +def _parse_ipv4_bgp_route( + out: str, allow_lo_routes: bool = False +) -> Dict[str, Dict[str, Set[str]]]: + """Parse JSON from ``ip -j route ls proto bgp``.""" + return _parse_bgp_routes_json(out, 4, allow_lo_routes) + + +def _parse_ipv6_bgp_route( + out: str, allow_lo_routes: bool = False +) -> Dict[str, Dict[str, Set[str]]]: + """Parse JSON from ``ip -6 -j route ls proto bgp``.""" + return _parse_bgp_routes_json(out, 6, allow_lo_routes) + + +def _list_ipv4_networks( + ctx: CephadmContext, + allow_lo_routes: bool = False, + allow_bgp_routes: bool = False, +) -> Dict[str, Dict[str, Set[str]]]: + """IPv4 networks from ``ip route ls`` (and optional ``proto bgp`` supplement). + + When *allow_lo_routes* is false, routes on ``lo`` are ignored by the parsers. + When true, ``_parse_ipv4_lo_route`` supplements the base table. + """ + execstr: Optional[str] = find_executable('ip') + if not execstr: + raise FileNotFoundError("unable to find 'ip' command") + out, _, _ = call_throws( + ctx, + [execstr, 'route', 'ls'], + verbosity=CallVerbosity.QUIET_UNLESS_ERROR, + ) + res = _parse_ipv4_route(out, allow_lo_routes) + if allow_lo_routes: + _merge_ipv4_network_dicts(res, _parse_ipv4_lo_route(out)) + if allow_bgp_routes: + bgp_out, _, _ = call_throws( + ctx, + [execstr, '-j', 'route', 'ls', 'proto', 'bgp'], + verbosity=CallVerbosity.QUIET_UNLESS_ERROR, + ) + _merge_ipv4_network_dicts( + res, _parse_ipv4_bgp_route(bgp_out, allow_lo_routes) + ) + return res + + def _list_ipv6_networks( ctx: CephadmContext, + allow_lo_routes: bool = False, + allow_bgp_routes: bool = False, ) -> Dict[str, Dict[str, Set[str]]]: + """IPv6 networks from ``ip -6 route`` and ``ip -6 addr``. + + When *allow_lo_routes* is false, routes on ``lo`` are ignored so loopback globals + are not listed. When *allow_bgp_routes* is true, ``ip -6 route ls proto bgp`` + is merged into the same shape as the main table (respecting *allow_lo_routes* + for ``lo`` BGP paths). + """ execstr: Optional[str] = find_executable('ip') if not execstr: raise FileNotFoundError("unable to find 'ip' command") @@ -957,11 +1185,23 @@ def _list_ipv6_networks( [execstr, '-6', 'addr', 'ls'], verbosity=CallVerbosity.QUIET_UNLESS_ERROR, ) - return _parse_ipv6_route(routes, ips) + res = _parse_ipv6_route(routes, ips, allow_lo_routes) + if allow_bgp_routes: + bgp_out, _, _ = call_throws( + ctx, + [execstr, '-6', '-j', 'route', 'ls', 'proto', 'bgp'], + verbosity=CallVerbosity.QUIET_UNLESS_ERROR, + ) + _merge_ipv4_network_dicts( + res, _parse_ipv6_bgp_route(bgp_out, allow_lo_routes) + ) + return res def _parse_ipv6_route( - routes: str, ips: str + routes: str, + ips: str, + allow_lo_routes: bool = False, ) -> Dict[str, Dict[str, Set[str]]]: r = {} # type: Dict[str, Dict[str, Set[str]]] route_p = re.compile( @@ -976,8 +1216,10 @@ def _parse_ipv6_route( net = m[0][0] if '/' not in net: # aggregate /128 mask for single host sub-networks net += '/128' - iface = m[0][1] - if iface == 'lo': # skip loopback devices + iface = m[0][1].split('@')[0] + if iface == 'lo' and not allow_lo_routes: + continue + if _is_ipv6_loopback(net): continue if net not in r: r[net] = {} diff --git a/src/cephadm/tests/test_networks.py b/src/cephadm/tests/test_networks.py index 5cc5fd00dc8..f854dfab1fb 100644 --- a/src/cephadm/tests/test_networks.py +++ b/src/cephadm/tests/test_networks.py @@ -48,6 +48,8 @@ class TestEndPoint: class TestCommandListNetworks: + # fmt: off + # Keep table-style parametrization unchanged for reviewable diffs (see PR discussion). @pytest.mark.parametrize("test_input, expected", [ ( dedent(""" @@ -106,9 +108,11 @@ class TestCommandListNetworks: } ), ]) + # fmt: on def test_parse_ipv4_route(self, test_input, expected): assert _parse_ipv4_route(test_input) == expected + # fmt: off @pytest.mark.parametrize("test_routes, test_ips, expected", [ ( dedent(""" @@ -236,7 +240,8 @@ class TestCommandListNetworks: ::1 dev lo proto kernel metric 256 pref medium fe80::/64 dev ceph-brx proto kernel metric 256 pref medium fe80::/64 dev brx.0 proto kernel metric 256 pref medium - default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium """), + default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium + """), dedent(""" 1: lo: mtu 65536 state UNKNOWN qlen 1000 inet6 ::1/128 scope host @@ -259,9 +264,38 @@ class TestCommandListNetworks: } ), ]) + # fmt: on def test_parse_ipv6_route(self, test_routes, test_ips, expected): assert _parse_ipv6_route(test_routes, test_ips) == expected + def test_parse_ipv6_route_includes_lo_global_not_slaac(self): + test_routes = dedent( + """ + ::1 dev lo proto kernel metric 256 pref medium + 2620:52:0:1304::71 dev lo proto kernel metric 256 pref medium + """ + ) + test_ips = dedent( + """ + 1: lo: mtu 65536 state UNKNOWN qlen 1000 + inet6 ::1/128 scope host + valid_lft forever preferred_lft forever + inet6 2620:52:0:1304::71/128 scope global + valid_lft forever preferred_lft forever + """ + ) + expected = { + '2620:52:0:1304::71/128': {'lo': {'2620:52:0:1304::71'}}, + } + assert ( + _parse_ipv6_route(test_routes, test_ips, allow_lo_routes=False) + == {} + ) + assert ( + _parse_ipv6_route(test_routes, test_ips, allow_lo_routes=True) + == expected + ) + @mock.patch('cephadmlib.net_utils.read_file') def test_get_ipv6_addr(self, _read_file): proc_net_if_net6 = """fe80000000000000505400fffe347999 02 40 20 80 eth0 @@ -277,8 +311,18 @@ fe80000000000000505400fffe04c154 03 40 20 80 eth1 @mock.patch('cephadmlib.host_facts.call_throws') @mock.patch('cephadmlib.host_facts.find_executable') - def test_command_list_networks(self, _find_exe, _call_throws, cephadm_fs, capsys): - _call_throws.return_value = ('10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n', '', '') + def test_command_list_networks( + self, _find_exe, _call_throws, cephadm_fs, capsys + ): + _call_throws.side_effect = [ + ( + '10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n', + '', + '', + ), + ('', '', ''), + ('', '', ''), + ] _find_exe.return_value = 'ip' with with_cephadm_ctx([]) as ctx: _cephadm.command_list_networks(ctx) diff --git a/src/cephadm/tests/test_networks_lo_routes.py b/src/cephadm/tests/test_networks_lo_routes.py new file mode 100644 index 00000000000..4e02c972e35 --- /dev/null +++ b/src/cephadm/tests/test_networks_lo_routes.py @@ -0,0 +1,349 @@ +import json +from textwrap import dedent + +from cephadmlib.host_facts import ( + _merge_ipv4_network_dicts, + _parse_ipv4_bgp_route, + _parse_ipv4_lo_route, + _parse_ipv4_route, + _parse_ipv6_bgp_route, +) + + +def _merged_ipv4(out: str, *, allow_lo: bool = False): + r = _parse_ipv4_route(out, allow_lo) + if allow_lo: + _merge_ipv4_network_dicts(r, _parse_ipv4_lo_route(out)) + return r + + +class TestLoopbackRouteParsing: + def test_parse_ipv4_route_omits_127_on_lo(self): + test_input = dedent( + """ + 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1 + 127.0.0.0/8 dev lo proto kernel scope link src 127.0.0.1 + """ + ) + expected = {'192.168.1.0/24': {'eth0': {'192.168.1.1'}}} + assert _parse_ipv4_route(test_input) == expected + + def test_parse_ipv4_route_includes_non_loopback_on_lo(self): + test_input = dedent( + """ + 192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1 + 10.168.100.0/24 dev lo proto kernel scope link src 10.168.100.10 + """ + ) + expected_with_lo = { + '192.168.1.0/24': {'eth0': {'192.168.1.1'}}, + '10.168.100.0/24': {'lo': {'10.168.100.10'}}, + } + assert _parse_ipv4_route(test_input) == {'192.168.1.0/24': {'eth0': {'192.168.1.1'}}} + assert _parse_ipv4_route(test_input, allow_lo_routes=True) == expected_with_lo + + def test_parse_ipv4_lo_route_adds_lo_host_route_without_src(self): + # ``ip route add …/32 dev lo proto bgp`` prints no ``src`` line. + test_input = dedent( + """ + 192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100 + 10.168.100.10 dev lo proto bgp scope link + """ + ) + expected = { + '192.168.100.0/24': {'ens3': {'192.168.100.100'}}, + '10.168.100.10/32': {'lo': {'10.168.100.10'}}, + } + assert _merged_ipv4(test_input, allow_lo=True) == expected + + def test_parse_ipv4_lo_route_scope_host_on_lo(self): + test_input = dedent( + """ + 192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100 + 10.10.10.90 dev lo proto kernel scope host + """ + ) + assert _parse_ipv4_route(test_input) == { + '192.168.100.0/24': {'ens3': {'192.168.100.100'}}, + } + assert _merged_ipv4(test_input, allow_lo=True) == { + '192.168.100.0/24': {'ens3': {'192.168.100.100'}}, + '10.10.10.90/32': {'lo': {'10.10.10.90'}}, + } + + def test_parse_ipv4_lo_route_bgp_scope_host_on_lo(self): + test_input = dedent( + """ + 10.10.10.10 dev lo proto bgp scope host + """ + ) + assert _parse_ipv4_route(test_input) == {} + assert _parse_ipv4_lo_route(test_input) == { + '10.10.10.10/32': {'lo': {'10.10.10.10'}}, + } + + def test_parse_ipv4_lo_route_dummy_scope_host(self): + # Host-scoped /32 on dummy (tracker.ceph.com/issues/76229). + test_input = dedent( + """ + 192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100 + 10.1.0.40 dev dummy0 proto kernel scope host src 10.1.0.40 + """ + ) + expected = { + '192.168.100.0/24': {'ens3': {'192.168.100.100'}}, + '10.1.0.40/32': {'dummy0': {'10.1.0.40'}}, + } + assert _merged_ipv4(test_input, allow_lo=True) == expected + + def test_parse_ipv4_bgp_route_ecmp_nexthops(self): + # From ``ip -j route ls proto bgp`` on a BGP host (ceph-node-0). + bgp_input = json.dumps( + [ + { + 'dst': '192.168.100.5', + 'protocol': 'bgp', + 'prefsrc': '192.168.100.11', + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + { + 'dst': '192.168.100.6', + 'protocol': 'bgp', + 'prefsrc': '192.168.100.11', + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + { + 'dst': '10.0.1.11', + 'protocol': 'bgp', + 'prefsrc': '10.0.1.11', + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + ] + ) + expected = { + '192.168.100.5/32': { + 'ens1f0np0': {'192.168.100.11'}, + 'ens1f1np1': {'192.168.100.11'}, + }, + '192.168.100.6/32': { + 'ens1f0np0': {'192.168.100.11'}, + 'ens1f1np1': {'192.168.100.11'}, + }, + '10.0.1.11/32': { + 'ens1f0np0': {'10.0.1.11'}, + 'ens1f1np1': {'10.0.1.11'}, + }, + } + assert _parse_ipv4_bgp_route(bgp_input) == expected + + def test_parse_ipv4_bgp_route_ecmp_without_protocol_field(self): + # ``ip -j route ls proto bgp`` may omit ``protocol`` on filtered output. + bgp_input = json.dumps( + [ + { + 'dst': '192.168.100.5', + 'prefsrc': '192.168.100.11', + 'metric': 20, + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + { + 'dst': '192.168.100.6', + 'prefsrc': '192.168.100.11', + 'metric': 20, + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + ] + ) + expected = { + '192.168.100.5/32': { + 'ens1f0np0': {'192.168.100.11'}, + 'ens1f1np1': {'192.168.100.11'}, + }, + '192.168.100.6/32': { + 'ens1f0np0': {'192.168.100.11'}, + 'ens1f1np1': {'192.168.100.11'}, + }, + } + assert _parse_ipv4_bgp_route(bgp_input) == expected + + def test_parse_ipv4_bgp_route_lo_without_src(self): + bgp_input = json.dumps( + [ + { + 'dst': '10.168.100.10', + 'dev': 'lo', + 'protocol': 'bgp', + 'scope': 'link', + 'flags': [], + } + ] + ) + assert _parse_ipv4_bgp_route(bgp_input) == {} + assert _parse_ipv4_bgp_route(bgp_input, allow_lo_routes=True) == { + '10.168.100.10/32': {'lo': {'10.168.100.10'}}, + } + + def test_parse_ipv4_bgp_route_lo_from_full_table(self): + # ``ip -j route ls`` entry for ``10.10.10.10 dev lo proto bgp``. + bgp_input = json.dumps( + [ + { + 'dst': '10.10.10.10', + 'dev': 'lo', + 'protocol': 'bgp', + 'scope': 'link', + 'flags': [], + } + ] + ) + assert _parse_ipv4_bgp_route(bgp_input, allow_lo_routes=True) == { + '10.10.10.10/32': {'lo': {'10.10.10.10'}}, + } + + def test_parse_ipv6_bgp_route_ecmp_nexthops(self): + bgp_input = json.dumps( + [ + { + 'dst': '2001:db8:100::5', + 'protocol': 'bgp', + 'prefsrc': '2001:db8:100::11', + 'metric': 20, + 'pref': 'medium', + 'flags': [], + 'nexthops': [ + { + 'gateway': 'fe80::1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': [], + }, + { + 'gateway': 'fe80::2', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': [], + }, + ], + }, + { + 'dst': '2001:db8:200::1:11', + 'protocol': 'bgp', + 'prefsrc': '2001:db8:200::1:11', + 'metric': 20, + 'pref': 'medium', + 'flags': [], + 'nexthops': [ + { + 'gateway': 'fe80::a', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': [], + }, + { + 'gateway': 'fe80::b', + 'dev': 'ens1f1np1', + 'weight': 1, + 'flags': [], + }, + ], + }, + ] + ) + expected = { + '2001:db8:100::5/128': { + 'ens1f0np0': {'2001:db8:100::11'}, + 'ens1f1np1': {'2001:db8:100::11'}, + }, + '2001:db8:200::1:11/128': { + 'ens1f0np0': {'2001:db8:200::1:11'}, + 'ens1f1np1': {'2001:db8:200::1:11'}, + }, + } + assert _parse_ipv6_bgp_route(bgp_input) == expected + + def test_parse_ipv6_bgp_route_lo_without_src(self): + bgp_input = json.dumps( + [ + { + 'dst': '2001:db8:bad:10::10', + 'dev': 'lo', + 'protocol': 'bgp', + 'scope': 'link', + 'flags': [], + } + ] + ) + assert _parse_ipv6_bgp_route(bgp_input) == {} + assert _parse_ipv6_bgp_route(bgp_input, allow_lo_routes=True) == { + '2001:db8:bad:10::10/128': {'lo': {'2001:db8:bad:10::10'}}, + } diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 0e47dcd8018..7dfb72381f1 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -199,6 +199,23 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): default=1 * 60, desc='Seconds for which to cache host facts data', ), + Option( + 'allow_lo_routes', + type='bool', + default=False, + desc='If true, cephadm list-networks is run with --allow-lo-routes so ' + 'loopback (lo) routes are included in host network facts; if false, ' + 'they are omitted (default).', + ), + Option( + 'allow_bgp_routes', + type='bool', + default=False, + desc='If true, cephadm list-networks is run with --allow-bgp-routes so ' + 'BGP routes from ``ip route ls proto bgp`` and ' + '``ip -6 route ls proto bgp`` are merged into host network facts; if ' + 'false (default), only the main IPv4 and IPv6 tables are used.', + ), Option( 'host_check_interval', type='secs', @@ -543,6 +560,8 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.device_cache_timeout = 0 self.daemon_cache_timeout = 0 self.facts_cache_timeout = 0 + self.allow_lo_routes = False + self.allow_bgp_routes = False self.host_check_interval = 0 self.stray_daemon_check_interval = 0 self.max_count_per_host = 0 diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 1f1b5c2fd6f..c37ea6f271d 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -438,9 +438,20 @@ class CephadmServe: def _refresh_host_networks(self, host: str) -> Optional[str]: try: + list_net_args: List[str] = [] + if self.mgr.allow_lo_routes: + list_net_args.append('--allow-lo-routes') + if self.mgr.allow_bgp_routes: + list_net_args.append('--allow-bgp-routes') with self.mgr.async_timeout_handler(host, 'cephadm list-networks'): networks = self.mgr.wait_async(self._run_cephadm_json( - host, 'mon', 'list-networks', [], no_fsid=True, log_output=self.mgr.log_refresh_metadata)) + host, + 'mon', + 'list-networks', + list_net_args, + no_fsid=True, + log_output=self.mgr.log_refresh_metadata, + )) except OrchestratorError as e: return str(e) From a9ba39df2a41e999d1cf15597df632beedc7b2f5 Mon Sep 17 00:00:00 2001 From: Nathan Hoad Date: Mon, 18 May 2026 15:23:53 -0400 Subject: [PATCH 054/596] common: Fix the default for rgw_gc_max_queue_size, per review. This should be 1KB short of the maximum, not 3MB over. Signed-off-by: Nathan Hoad --- src/common/options/rgw.yaml.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index b52d85d758a..fd3759e3fc8 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -1968,7 +1968,7 @@ options: desc: Maximum allowed queue size for gc long_desc: The maximum allowed size of each gc queue, and its value should not be greater than osd_max_object_size - 1K. - default: 134068_K + default: 131071_K services: - rgw see_also: From ac6dd5571eaf5e2bb77d3e7af41448348100c8e4 Mon Sep 17 00:00:00 2001 From: Jaya Prakash Date: Tue, 19 May 2026 17:02:22 +0000 Subject: [PATCH 055/596] qa: fix TEST_mon_features persistent feature checks in mon/misc.sh The test reused the "tentacle" jq filter while validating "nvmeof_beacon_diff", causing the comparison to fail. Also fix the umbrella feature validation and update the expected persistent feature count. Fixes: https://tracker.ceph.com/issues/76472 Signed-off-by: Jaya Prakash --- qa/standalone/mon/misc.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qa/standalone/mon/misc.sh b/qa/standalone/mon/misc.sh index f136db0eb78..e5d3e409348 100755 --- a/qa/standalone/mon/misc.sh +++ b/qa/standalone/mon/misc.sh @@ -285,10 +285,11 @@ function TEST_mon_features() { jq_success "$jqinput" "$jqfilter" "squid" || return 1 jqfilter='.monmap.features.persistent[]|select(. == "tentacle")' jq_success "$jqinput" "$jqfilter" "tentacle" || return 1 + jqfilter='.monmap.features.persistent[]|select(. == "nvmeof_beacon_diff")' jq_success "$jqinput" "$jqfilter" "nvmeof_beacon_diff" || return 1 jqfilter='.monmap.features.persistent[]|select(. == "umbrella")' - jq_success "$jqinput" "$jqfilter" "tentacle" || return 1 - jqfilter='.monmap.features.persistent | length == 13' + jq_success "$jqinput" "$jqfilter" "umbrella" || return 1 + jqfilter='.monmap.features.persistent | length == 14' jq_success "$jqinput" "$jqfilter" || return 1 CEPH_ARGS=$CEPH_ARGS_orig From b066d4cd3a3c7821f93bb7cc5390b97a19e22e94 Mon Sep 17 00:00:00 2001 From: Neeraj Pratap Singh Date: Wed, 20 May 2026 22:53:09 +0530 Subject: [PATCH 056/596] qa: fixing failures in TestShellOpts We were seeing failures in TestShellOpts again and again due the (newline added within the output) output structure change with the cmd2 releases. Now, we directly extract the final output of 'set editor' command and use it, instead of traversing on complete output through hardcoded indexes.This fix is better than earlier hardcoded splitting/striping via indexes. Fixes: https://tracker.ceph.com/issues/71795 Signed-off-by: Neeraj Pratap Singh --- qa/tasks/cephfs/test_cephfs_shell.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/qa/tasks/cephfs/test_cephfs_shell.py b/qa/tasks/cephfs/test_cephfs_shell.py index 0864c1e5a79..0cc4dab7760 100644 --- a/qa/tasks/cephfs/test_cephfs_shell.py +++ b/qa/tasks/cephfs/test_cephfs_shell.py @@ -1109,6 +1109,17 @@ class TestShellOpts(TestCephFSShell): Contains tests for shell options from conf file and shell prompt. """ + def extract_set_editor_output(self, out): + res = None + for line in out.splitlines(): + line_parts = line.split() + if len(line_parts) > 4 and line_parts[0] == "editor": + res = line_parts + break + + self.assertIsNotNone(res, f"didn't find editor output in: {out}") + return res[1] + def setUp(self): super(type(self), self).setUp() @@ -1119,9 +1130,8 @@ class TestShellOpts(TestCephFSShell): # ==================================================================================================== # editor ? Program used by 'edit' self.editor_val = self.get_cephfs_shell_cmd_output( - 'set editor ?, set editor').split('\n')[4] - self.editor_val = self.editor_val.split()[1].strip(). \ - replace("'", "", 2) + 'set editor ?, set editor') + self.editor_val = self.extract_set_editor_output(self.editor_val) def write_tempconf(self, confcontents): self.tempconfpath = self.mount_a.client_remote.mktemp( @@ -1139,9 +1149,7 @@ class TestShellOpts(TestCephFSShell): # editor ??? Program used by 'edit' final_editor_val = self.get_cephfs_shell_cmd_output( cmd='set editor', shell_conf_path=self.tempconfpath) - final_editor_val = final_editor_val.split('\n')[2] - final_editor_val = final_editor_val.split()[1].strip(). \ - replace("'", "", 2) + final_editor_val = self.extract_set_editor_output(final_editor_val) self.assertNotEqual(self.editor_val, final_editor_val) @@ -1159,9 +1167,7 @@ class TestShellOpts(TestCephFSShell): # editor ? Program used by 'edit' final_editor_val = self.get_cephfs_shell_cmd_output( cmd='set editor', shell_conf_path=self.tempconfpath) - final_editor_val = final_editor_val.split('\n')[2] - final_editor_val = final_editor_val.split()[1].strip(). \ - replace("'", "", 2) + final_editor_val = self.extract_set_editor_output(final_editor_val) self.assertEqual(self.editor_val, final_editor_val) @@ -1177,8 +1183,6 @@ class TestShellOpts(TestCephFSShell): final_editor_val = self.get_cephfs_shell_cmd_output( cmd='set editor %s, set editor' % self.editor_val, shell_conf_path=self.tempconfpath) - final_editor_val = final_editor_val.split('\n')[4] - final_editor_val = final_editor_val.split()[1].strip(). \ - replace("'", "", 2) + final_editor_val = self.extract_set_editor_output(final_editor_val) self.assertEqual(self.editor_val, final_editor_val) From 8e32fff1734c9594c95474359c765baa6a7b3631 Mon Sep 17 00:00:00 2001 From: Leonid Chernin Date: Fri, 10 Apr 2026 11:15:20 +0300 Subject: [PATCH 057/596] nvmeofgw:fix forcing unavailable gw exit by sending empty map to it Signed-off-by: Leonid Chernin --- src/mon/NVMeofGwMap.cc | 9 +++++---- src/mon/NVMeofGwMon.cc | 11 +++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/mon/NVMeofGwMap.cc b/src/mon/NVMeofGwMap.cc index 6cf45bcd7bf..e7eb42c91b4 100644 --- a/src/mon/NVMeofGwMap.cc +++ b/src/mon/NVMeofGwMap.cc @@ -43,10 +43,11 @@ void NVMeofGwMap::to_gmap( const auto& gw_id = gw_created_pair.first; const auto& gw_created = gw_created_pair.second; gw_availability_t availability = gw_created.availability; - if (gw_created.availability == gw_availability_t::GW_DELETING) { - dout (4) << gw_id << "Send empty unicast map in Deleting state" - << dendl; - continue; + if (gw_created.availability == gw_availability_t::GW_DELETING || + gw_created.availability == gw_availability_t::GW_UNAVAILABLE) { + dout (4) << "GW " << gw_id << " Send empty unicast map in state " + << gw_created.availability << dendl; + continue; } auto gw_state = NvmeGwClientState( diff --git a/src/mon/NVMeofGwMon.cc b/src/mon/NVMeofGwMon.cc index fe8bf32318b..8635da1c5e7 100644 --- a/src/mon/NVMeofGwMon.cc +++ b/src/mon/NVMeofGwMon.cc @@ -1116,6 +1116,11 @@ bool NVMeofGwMon::prepare_beacon(MonOpRequestRef op) << " GW state in monitor data-base : " << pending_map.created_gws[group_key][gw_id].availability << dendl; + if (pending_map.created_gws[group_key][gw_id].availability == + gw_availability_t::GW_UNAVAILABLE) { + pending_map.created_gws[group_key][gw_id].availability = + gw_availability_t::GW_CREATED; // prevent sending empty map to this GW after restart + } if (pending_map.created_gws[group_key][gw_id].availability == gw_availability_t::GW_AVAILABLE) { dout(1) << " Warning :GW marked as Available in the NVmeofGwMon " @@ -1150,11 +1155,9 @@ bool NVMeofGwMon::prepare_beacon(MonOpRequestRef op) false) && (avail == gw_availability_t::GW_AVAILABLE || avail == gw_availability_t::GW_UNAVAILABLE )) { - ack_map.created_gws[group_key][gw_id] = - pending_map.created_gws[group_key][gw_id]; ack_map.epoch = get_ack_map_epoch(true, group_key); - dout(1) << " Force gw to exit: first beacon in state " << avail - << " GW " << gw_id << dendl; + dout(1) << "Send empty map. Force gw to exit: first beacon in state " + << avail << " GW " << gw_id << dendl; auto msg = make_message(ack_map); mon.send_reply(op, msg.detach()); goto false_return; From b2f5d1876ef9e30449f8d95df8fe0b6fd277f01c Mon Sep 17 00:00:00 2001 From: Matt Benjamin Date: Tue, 12 May 2026 12:48:43 -0400 Subject: [PATCH 058/596] rgwlc: fix a likely null dereference in LCObjsLister::get_obj() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by Opus 4.6: In get_obj() (lines 417-446), when obj_iter == list_results.objs.end() and the listing is truncated, the code calls fetch() to get the next batch. fetch() sets obj_iter = list_results.objs.begin(). But if the new fetch returns zero objects (possible if objects were deleted between listing batches, or due to filtering/race conditions), then begin() == end() and the code falls through to line 437: if (obj_iter->key.name == pre_obj.key.name) { This dereferences end() — undefined behavior / crash. The return-value check at line 445 (return obj_iter != list_results.objs.end()) comes too late; the dereference already happened. Fixes: https://tracker.ceph.com/issues/76790 Signed-off-by: Matt Benjamin Assisted-by: Claude Code, Opus 4.6 1M --- src/rgw/rgw_lc.cc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_lc.cc b/src/rgw/rgw_lc.cc index fdfbbc5abf2..53ad1326c7a 100644 --- a/src/rgw/rgw_lc.cc +++ b/src/rgw/rgw_lc.cc @@ -527,6 +527,9 @@ public: } } delay(dpp); + if (obj_iter == list_results.objs.end()) { + return false; + } } if (obj_iter->key.name == pre_obj.key.name) { @@ -537,7 +540,7 @@ public: /* returning address of entry in objs */ *obj = &(*obj_iter); - return obj_iter != list_results.objs.end(); + return true; } rgw_bucket_dir_entry get_prev_obj() { From 981d678971783a87e9ffd9c73fe1849d4b366992 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Tue, 26 May 2026 12:36:03 +0800 Subject: [PATCH 059/596] crimson/os/seastore/cached_extent: renew mutation_pending extents' last_committed_crc when committing rewrite transactions Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/cached_extent.cc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/crimson/os/seastore/cached_extent.cc b/src/crimson/os/seastore/cached_extent.cc index 18af8083d33..85480f470ee 100644 --- a/src/crimson/os/seastore/cached_extent.cc +++ b/src/crimson/os/seastore/cached_extent.cc @@ -458,6 +458,10 @@ void ExtentCommitter::_share_prior_data_to_mutations() { } } }); + // "me" is the actual prev of mextent, so before mextent enters + // the "prepare" pipeline phase, its last_committed_crc should be + // that of "me"'s + mextent.set_last_committed_crc(me.get_last_committed_crc()); } else { auto &mextent = static_cast(mext); TRACE("{} -> {}", extent, mextent); @@ -465,6 +469,7 @@ void ExtentCommitter::_share_prior_data_to_mutations() { 0, extent.get_length(), mextent.get_bptr().c_str()); mextent.on_data_commit(); mextent.reapply_delta(); + mextent.set_last_committed_crc(extent.get_last_committed_crc()); } } } From 3a9ab7a5f60d3e72ea3421eb9744e3e0d3ceed2f Mon Sep 17 00:00:00 2001 From: Harsimran Singh Date: Tue, 26 May 2026 11:41:06 +0530 Subject: [PATCH 060/596] rgw: add HEAD Object op metrics to perf counters and Prometheus export Expose HEAD Object operation metrics in the same format as existing RGW op metrics so operators can track HEAD request volume and latency at gateway, user, and bucket scope. Previously, HEAD Object requests were silently counted under get_obj_ops because RGWGetObj handles both GET and HEAD. The HEAD-specific early-return path (get_data == false) had no counter instrumentation. Fixes: ISCE-3042 Signed-off-by: Harsimran Singh --- doc/radosgw/metrics.rst | 18 ++++++++++++++++++ src/rgw/rgw_op.cc | 5 +++++ src/rgw/rgw_perf_counters.cc | 3 +++ src/rgw/rgw_perf_counters.h | 5 ++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/doc/radosgw/metrics.rst b/doc/radosgw/metrics.rst index e27babcbaae..74b3a44d15a 100644 --- a/doc/radosgw/metrics.rst +++ b/doc/radosgw/metrics.rst @@ -74,6 +74,12 @@ The following metrics related to S3 or Swift operations are tracked per Ceph Obj * - list_bucket_lat - Gauge - Total latency of list bucket operations + * - head_obj_ops + - Counter + - Number of successful HEAD Object operations + * - head_obj_lat + - Gauge + - Total latency of HEAD Object operations There are three different sections in the output of the ``counter dump`` and ``counter schema`` commands that show the op metrics and their information. The sections are ``rgw_op``, ``rgw_op_per_user``, and ``rgw_op_per_bucket``. @@ -109,6 +115,12 @@ To view op metrics in the Ceph Object Gateway go to the ``rgw_op`` sections of t "avgcount": 1, "sum": 0.002300000, "avgtime": 0.002300000 + }, + "head_obj_ops": 3, + "head_obj_lat": { + "avgcount": 3, + "sum": 0.006123456, + "avgtime": 0.002041152 } } }, @@ -146,6 +158,12 @@ Op metrics can also be tracked per-user or per-bucket. These metrics are exporte "avgcount": 1, "sum": 0.002300000, "avgtime": 0.002300000 + }, + "head_obj_ops": 3, + "head_obj_lat": { + "avgcount": 3, + "sum": 0.006123456, + "avgtime": 0.002041152 } } }, diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index b9529e2524c..96baccea30a 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -2639,6 +2639,7 @@ void RGWGetObj::execute(optional_yield y) if (get_type() == RGW_OP_STAT_OBJ) { return; } + if (s->info.env->exists("HTTP_X_RGW_AUTH")) { op_ret = 0; goto done_err; @@ -2815,6 +2816,10 @@ void RGWGetObj::execute(optional_yield y) if (!get_data || ofs > end) { send_response_data(bl, 0, 0); + if (!get_data) { + rgw::op_counters::inc(counters, l_rgw_op_head_obj, 1); + rgw::op_counters::tinc(counters, l_rgw_op_head_obj_lat, s->time_elapsed()); + } return; } diff --git a/src/rgw/rgw_perf_counters.cc b/src/rgw/rgw_perf_counters.cc index 6019e449977..a5e0072e95a 100644 --- a/src/rgw/rgw_perf_counters.cc +++ b/src/rgw/rgw_perf_counters.cc @@ -93,6 +93,9 @@ void add_rgw_op_counters(PerfCountersBuilder *lpcb) { lpcb->add_u64_counter(l_rgw_op_list_buckets, "list_buckets_ops", "List buckets"); lpcb->add_time_avg(l_rgw_op_list_buckets_lat, "list_buckets_lat", "List buckets latency"); + + lpcb->add_u64_counter(l_rgw_op_head_obj, "head_obj_ops","Head object operations"); + lpcb->add_time_avg(l_rgw_op_head_obj_lat, "head_obj_lat","Head object latency"); } void add_rgw_topic_counters(PerfCountersBuilder *lpcb) { diff --git a/src/rgw/rgw_perf_counters.h b/src/rgw/rgw_perf_counters.h index e93b4fe36f9..5c3e724d26c 100644 --- a/src/rgw/rgw_perf_counters.h +++ b/src/rgw/rgw_perf_counters.h @@ -83,7 +83,10 @@ enum { l_rgw_op_list_buckets, l_rgw_op_list_buckets_lat, - + + l_rgw_op_head_obj, + l_rgw_op_head_obj_lat, + l_rgw_op_last }; From 21be389c6796eb5b98829cb0cde0b6d99ea25c30 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 27 May 2026 19:38:27 +0000 Subject: [PATCH 061/596] rgw/multisite: do not log EBUSY/EAGAIN in per-object sync error log The bucket-instance level sync (RGWDataSyncSingleEntryCR) excludes EBUSY and EAGAIN from the sync error log as these are meant to be transient errors that resolve on retry without an admin intervention. Also, add EBUSY and EAGAIN to ignore_sync_error(), which is used by the per-object level sync in RGWSyncObjectCR, to match the bucket-instance level behavior. Fixes: https://tracker.ceph.com/issues/76950 Signed-off-by: Oguzhan Ozmen --- src/rgw/driver/rados/rgw_data_sync.cc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rgw/driver/rados/rgw_data_sync.cc b/src/rgw/driver/rados/rgw_data_sync.cc index 3b742b85706..3901e8c8efc 100644 --- a/src/rgw/driver/rados/rgw_data_sync.cc +++ b/src/rgw/driver/rados/rgw_data_sync.cc @@ -4327,6 +4327,8 @@ static bool ignore_sync_error(int err) { switch (err) { case -ENOENT: case -EPERM: + case -EBUSY: + case -EAGAIN: return true; default: break; From c29b9f0c86d93bbbca5c80058db898134b48a991 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 27 May 2026 19:38:37 +0000 Subject: [PATCH 062/596] rgw/multisite: include error code in object sync failure log message Append retcode and its string representation to the log line to facilitate the diagnosis of sync issues. Signed-off-by: Oguzhan Ozmen --- src/rgw/driver/rados/rgw_data_sync.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rgw/driver/rados/rgw_data_sync.cc b/src/rgw/driver/rados/rgw_data_sync.cc index 3901e8c8efc..b437ab4252e 100644 --- a/src/rgw/driver/rados/rgw_data_sync.cc +++ b/src/rgw/driver/rados/rgw_data_sync.cc @@ -4495,7 +4495,9 @@ public: if (retcode < 0 && retcode != -ENOENT) { set_status() << "failed to sync obj; retcode=" << retcode; tn->log(0, SSTR("ERROR: failed to sync object: " - << bucket_shard_str{bs} << "/" << key.name)); + << bucket_shard_str{bs} << "/" << key.name + << " retcode=" << retcode + << " (" << cpp_strerror(-retcode) << ")")); if (!ignore_sync_error(retcode)) { error_ss << bucket_shard_str{bs} << "/" << key.name; sync_status = retcode; From 5a45cd213e52fc3dabcccc5ba85706205468a732 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Wed, 16 Oct 2024 19:53:20 +0200 Subject: [PATCH 063/596] include/CompatSet: use std::string_view in struct Feature Almost all callers pass a C string (in temporary Feature instances), only `CompatSetHandler::handle()` passes a `std::string` reference. Allocating a new `std::string` is useless overhead. This patch reduces the binary size by 21 kB. Signed-off-by: Max Kellermann --- src/include/CompatSet.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/include/CompatSet.h b/src/include/CompatSet.h index 9d63edc55a9..24cadd6ab57 100644 --- a/src/include/CompatSet.h +++ b/src/include/CompatSet.h @@ -29,9 +29,9 @@ struct CompatSet { struct Feature { uint64_t id; - std::string name; + std::string_view name; - Feature(uint64_t _id, const std::string& _name) : id(_id), name(_name) {} + constexpr Feature(uint64_t _id, const std::string_view _name) : id(_id), name(_name) {} }; class FeatureSet { From 7b5e7c42605dfde5c2256d628f71a8f586f7b403 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Wed, 16 Oct 2024 20:01:20 +0200 Subject: [PATCH 064/596] include/CompatSet: get_name() returns std::string_view Creating a temporary copy is useless overhead for all callers. Signed-off-by: Max Kellermann --- src/include/CompatSet.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/include/CompatSet.h b/src/include/CompatSet.h index 24cadd6ab57..f56ce49aa4d 100644 --- a/src/include/CompatSet.h +++ b/src/include/CompatSet.h @@ -62,7 +62,7 @@ struct CompatSet { /** * Getter instead of using name[] to be const safe */ - std::string get_name(uint64_t const f) const { + std::string_view get_name(uint64_t const f) const noexcept { std::map::const_iterator i = names.find(f); ceph_assert(i != names.end()); return i->second; From 2718fd2792afd9c1febeadcd07011daf0421cab7 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Wed, 16 Oct 2024 20:30:09 +0200 Subject: [PATCH 065/596] include/CompatSet: remove the FeatureSet ctor Signed-off-by: Max Kellermann --- src/include/CompatSet.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/include/CompatSet.h b/src/include/CompatSet.h index f56ce49aa4d..9ab3794adec 100644 --- a/src/include/CompatSet.h +++ b/src/include/CompatSet.h @@ -35,7 +35,7 @@ struct CompatSet { }; class FeatureSet { - uint64_t mask; + uint64_t mask = 1; std::map names; public: @@ -45,7 +45,7 @@ struct CompatSet { friend class CephCompatSet_merge_Test; friend std::ostream& operator<<(std::ostream& out, const CompatSet::FeatureSet& fs); friend std::ostream& operator<<(std::ostream& out, const CompatSet& compat); - FeatureSet() : mask(1), names() {} + void insert(const Feature& f) { ceph_assert(f.id > 0); ceph_assert(f.id < 64); From 72483c1eb0bae5caaee26090e053d24c6500e919 Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Wed, 27 May 2026 16:22:14 -0400 Subject: [PATCH 066/596] rgw: use local error code in handle_individual_object() op_ret is a member variable of RGWDeleteMultiObj shared between concurrent callers of handle_individual_object(). because that coroutine may suspend between writing to op_ret and later reading it back for send_partial_response(), it's likely that other callers have modified it in the meantime store this error code in a local variable instead, so each coroutine has its own consistent copy of it Fixes: https://tracker.ceph.com/issues/76960 Signed-off-by: Casey Bodley --- src/rgw/rgw_op.cc | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index 9809a481f90..e7c21d1aa3f 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -8079,9 +8079,9 @@ void RGWDeleteMultiObj::handle_individual_object(const RGWMultiDelObject& object rgw::notify::ObjectRemovedDelete; std::unique_ptr res = driver->get_notification(obj.get(), s->src_object.get(), s, event_type, y); - op_ret = res->publish_reserve(dpp); - if (op_ret < 0) { - send_partial_response(o, false, "", op_ret); + int r = res->publish_reserve(dpp); + if (r < 0) { + send_partial_response(o, false, "", r); return; } @@ -8097,10 +8097,10 @@ void RGWDeleteMultiObj::handle_individual_object(const RGWMultiDelObject& object del_op->params.if_match = object.get_if_match(); del_op->params.size_match = object.get_size_match(); - op_ret = del_op->delete_obj(dpp, y, - rgw::sal::FLAG_LOG_OP | (skip_olh_obj_update ? rgw::sal::FLAG_SKIP_UPDATE_OLH : 0)); - if (op_ret == -ENOENT) { - op_ret = 0; + r = del_op->delete_obj(dpp, y, + rgw::sal::FLAG_LOG_OP | (skip_olh_obj_update ? rgw::sal::FLAG_SKIP_UPDATE_OLH : 0)); + if (r == -ENOENT) { + r = 0; } if (auto ret = rgw::bucketlogging::log_record(driver, rgw::bucketlogging::LoggingType::Any, obj.get(), s, canonical_name(), etag, obj_size, this, y, true, false); ret < 0) { @@ -8108,7 +8108,7 @@ void RGWDeleteMultiObj::handle_individual_object(const RGWMultiDelObject& object ldpp_dout(this, 5) << "WARNING: multi DELETE operation ignores bucket logging failure: " << ret << dendl; } - if (op_ret == 0) { + if (r == 0) { // send request to notification manager int ret = res->publish_commit(dpp, obj_size, ceph::real_clock::now(), etag, version_id); if (ret < 0) { @@ -8117,7 +8117,7 @@ void RGWDeleteMultiObj::handle_individual_object(const RGWMultiDelObject& object } } - send_partial_response(o, del_op->result.delete_marker, del_op->result.version_id, op_ret); + send_partial_response(o, del_op->result.delete_marker, del_op->result.version_id, r); } void RGWDeleteMultiObj::handle_objects(const std::vector& objects, From 4681cb7b3429f64caeec2602a56a03c1f83f4c25 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Thu, 28 May 2026 13:52:00 -0400 Subject: [PATCH 067/596] qa/suites/upgrade: ignore fs down variant Fixes: https://tracker.ceph.com/issues/76969 Signed-off-by: Patrick Donnelly --- qa/overrides/upgrade_ignorelist_health.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qa/overrides/upgrade_ignorelist_health.yaml b/qa/overrides/upgrade_ignorelist_health.yaml index 4cefcafc06c..5d175caf73d 100644 --- a/qa/overrides/upgrade_ignorelist_health.yaml +++ b/qa/overrides/upgrade_ignorelist_health.yaml @@ -22,5 +22,7 @@ overrides: - osds down - MDS_INSUFFICIENT_STANDBY - insufficient standby + - fs has.*but wants - have.*want.*more + - fs .* is degraded - is offline because no MDS is active for it From 43dd4cbd370455590d12337f09138a7f7b635bd9 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 28 May 2026 19:11:24 +0800 Subject: [PATCH 068/596] rocksdb: upgrade submodule to v7.10.2 to address CVE-2022-23476 This commit upgrades the rocksdb submodule from v7.9.2 to v7.10.2 to address CVE-2022-23476, a security vulnerability in the Nokogiri gem used by RocksDB's documentation build system. CVE-2022-23476 Details: - Affects: RocksDB versions < 7.10.2 - Issue: Unchecked return value from xmlTextReaderExpand in Nokogiri - Fix commit: 6648dec0a3eee0a329af8342038ce099baa55122 - Fixed in: Nokogiri 1.13.10, included in RocksDB v7.10.2 The vulnerability was fixed in the upstream facebook/rocksdb repository on December 8, 2022, and included in the v7.10.2 release. The v7.9.2 version currently used by Ceph does not contain this fix, as the 7.9.x branch diverged before the security patch was applied. Analysis confirmed that: - Current Ceph RocksDB: v7.9.2 (commit 24ea35870fe9b3ba15285ec8746ba97ed5d67ff3) - CVE fix present in v7.9.2: NO - First version with fix: v7.10.2 (commit 9107c1059b4656513ae0d51e8976e4f69f59a9c3) References: - CVE-2022-23476: https://nvd.nist.gov/vuln/detail/CVE-2022-23476 - Nokogiri Advisory: GHSA-qv4q-mr5r-qprj - Fix commit: facebook/rocksdb@6648dec also, in this change, we adapt BinnedLRUCache to CacheItemHelper API. rocksdb 7.10 reshaped `rocksdb::Cache`, so we have to update our overrides accordingly to address the build failure. the check for RocksDB version is dropped, as mainstream distros ship RocksDB >= 7.10.2 at the time of writing: - alpine 3.23: 10.9.1 - fedora 44: 10.2.1 - debian stable: 9.10.0 - ubuntu nobel: 8.9.1 - ubuntu resolute: 9.11.2 Signed-off-by: Justin Caratzas Signed-off-by: Kefu Chai --- src/kv/rocksdb_cache/BinnedLRUCache.cc | 27 ++++++------- src/kv/rocksdb_cache/BinnedLRUCache.h | 27 ++++++------- src/kv/rocksdb_cache/ShardedCache.cc | 43 ++++++++++----------- src/kv/rocksdb_cache/ShardedCache.h | 52 ++++++++++++++------------ src/rocksdb | 2 +- 5 files changed, 77 insertions(+), 74 deletions(-) diff --git a/src/kv/rocksdb_cache/BinnedLRUCache.cc b/src/kv/rocksdb_cache/BinnedLRUCache.cc index b2344032f1d..be51b0325a9 100644 --- a/src/kv/rocksdb_cache/BinnedLRUCache.cc +++ b/src/kv/rocksdb_cache/BinnedLRUCache.cc @@ -160,9 +160,9 @@ void BinnedLRUCacheShard::EraseUnRefEntries() { void BinnedLRUCacheShard::ApplyToAllCacheEntries( const std::function& callback, + const rocksdb::Cache::CacheItemHelper* helper)>& callback, bool thread_safe) { if (thread_safe) { @@ -170,7 +170,7 @@ void BinnedLRUCacheShard::ApplyToAllCacheEntries( } table_.ApplyToAllCacheEntries( [callback](BinnedLRUHandle* h) { - callback(h->key(), h->value, h->charge, h->deleter); + callback(h->key(), h->value, h->charge, h->helper); }); if (thread_safe) { mutex_.unlock(); @@ -419,16 +419,17 @@ bool BinnedLRUCacheShard::Release(rocksdb::Cache::Handle* handle, bool force_era return last_reference; } -rocksdb::Status BinnedLRUCacheShard::Insert(const rocksdb::Slice& key, uint32_t hash, void* value, +rocksdb::Status BinnedLRUCacheShard::Insert(const rocksdb::Slice& key, uint32_t hash, + rocksdb::Cache::ObjectPtr value, + const rocksdb::Cache::CacheItemHelper* helper, size_t charge, - DeleterFn deleter, rocksdb::Cache::Handle** handle, rocksdb::Cache::Priority priority) { auto e = new BinnedLRUHandle(); rocksdb::Status s; BinnedLRUHandle* deleted = nullptr; e->value = value; - e->deleter = deleter; + e->helper = helper; e->charge = charge; e->key_length = key.size(); e->key_data = new char[e->key_length]; @@ -560,10 +561,11 @@ std::string BinnedLRUCacheShard::GetPrintableOptions() const { return std::string(buffer); } -DeleterFn BinnedLRUCacheShard::GetDeleter(rocksdb::Cache::Handle* h) const +const rocksdb::Cache::CacheItemHelper* +BinnedLRUCacheShard::GetCacheItemHelper(rocksdb::Cache::Handle* h) const { auto* handle = reinterpret_cast(h); - return handle->deleter; + return handle->helper; } #undef dout_context @@ -702,7 +704,7 @@ const CacheShard* BinnedLRUCache::GetShard(int shard) const { return reinterpret_cast(&shards_[shard]); } -void* BinnedLRUCache::Value(Handle* handle) { +rocksdb::Cache::ObjectPtr BinnedLRUCache::Value(Handle* handle) { return reinterpret_cast(handle)->value; } @@ -721,12 +723,11 @@ void BinnedLRUCache::DisownData() { #endif // !__SANITIZE_ADDRESS__ } -#if (ROCKSDB_MAJOR >= 7 || (ROCKSDB_MAJOR == 6 && ROCKSDB_MINOR >= 22)) -DeleterFn BinnedLRUCache::GetDeleter(Handle* handle) const +const rocksdb::Cache::CacheItemHelper* +BinnedLRUCache::GetCacheItemHelper(Handle* handle) const { - return reinterpret_cast(handle)->deleter; + return reinterpret_cast(handle)->helper; } -#endif size_t BinnedLRUCache::TEST_GetLRUSize() { size_t lru_size_of_all_shards = 0; diff --git a/src/kv/rocksdb_cache/BinnedLRUCache.h b/src/kv/rocksdb_cache/BinnedLRUCache.h index a419154c277..44392e7d4bc 100644 --- a/src/kv/rocksdb_cache/BinnedLRUCache.h +++ b/src/kv/rocksdb_cache/BinnedLRUCache.h @@ -58,8 +58,8 @@ std::shared_ptr NewBinnedLRUCache( struct BinnedLRUHandle { std::shared_ptr age_bin; - void* value; - DeleterFn deleter; + rocksdb::Cache::ObjectPtr value; + const rocksdb::Cache::CacheItemHelper* helper; BinnedLRUHandle* next_hash; BinnedLRUHandle* next; BinnedLRUHandle* prev; @@ -121,8 +121,8 @@ struct BinnedLRUHandle { void Free() { ceph_assert((refs == 1 && InCache()) || (refs == 0 && !InCache())); - if (deleter) { - (*deleter)(key(), value); + if (helper && helper->del_cb) { + (*helper->del_cb)(value, /*allocator=*/nullptr); } delete[] key_data; delete this; @@ -240,9 +240,10 @@ class alignas(CACHE_LINE_SIZE) BinnedLRUCacheShard : public CacheShard { void SetHighPriPoolRatio(double high_pri_pool_ratio); // Like Cache methods, but with an extra "hash" parameter. - virtual rocksdb::Status Insert(const rocksdb::Slice& key, uint32_t hash, void* value, + virtual rocksdb::Status Insert(const rocksdb::Slice& key, uint32_t hash, + rocksdb::Cache::ObjectPtr value, + const rocksdb::Cache::CacheItemHelper* helper, size_t charge, - DeleterFn deleter, rocksdb::Cache::Handle** handle, rocksdb::Cache::Priority priority) override; virtual rocksdb::Cache::Handle* Lookup(const rocksdb::Slice& key, uint32_t hash) override; @@ -260,16 +261,17 @@ class alignas(CACHE_LINE_SIZE) BinnedLRUCacheShard : public CacheShard { virtual void ApplyToAllCacheEntries( const std::function& callback, + const rocksdb::Cache::CacheItemHelper* helper)>& callback, bool thread_safe) override; virtual void EraseUnRefEntries() override; virtual std::string GetPrintableOptions() const override; - virtual DeleterFn GetDeleter(rocksdb::Cache::Handle* handle) const override; + virtual const rocksdb::Cache::CacheItemHelper* GetCacheItemHelper( + rocksdb::Cache::Handle* handle) const override; void TEST_GetLRUList(BinnedLRUHandle** lru, BinnedLRUHandle** lru_low_pri); @@ -382,13 +384,12 @@ class BinnedLRUCache : public ShardedCache { virtual const char* Name() const override { return "BinnedLRUCache"; } virtual CacheShard* GetShard(int shard) override; virtual const CacheShard* GetShard(int shard) const override; - virtual void* Value(Handle* handle) override; + virtual rocksdb::Cache::ObjectPtr Value(Handle* handle) override; virtual size_t GetCharge(Handle* handle) const override; virtual uint32_t GetHash(Handle* handle) const override; virtual void DisownData() override; -#if (ROCKSDB_MAJOR >= 7 || (ROCKSDB_MAJOR == 6 && ROCKSDB_MINOR >= 22)) - virtual DeleterFn GetDeleter(Handle* handle) const override; -#endif + virtual const rocksdb::Cache::CacheItemHelper* GetCacheItemHelper( + Handle* handle) const override; // Retrieves number of elements in LRU, for unit test purpose only size_t TEST_GetLRUSize(); // Sets the high pri pool ratio diff --git a/src/kv/rocksdb_cache/ShardedCache.cc b/src/kv/rocksdb_cache/ShardedCache.cc index 7d160f9c7b4..b6fcb01174f 100644 --- a/src/kv/rocksdb_cache/ShardedCache.cc +++ b/src/kv/rocksdb_cache/ShardedCache.cc @@ -43,15 +43,23 @@ void ShardedCache::SetStrictCapacityLimit(bool strict_capacity_limit) { strict_capacity_limit_ = strict_capacity_limit; } -rocksdb::Status ShardedCache::Insert(const rocksdb::Slice& key, void* value, size_t charge, - DeleterFn deleter, +rocksdb::Status ShardedCache::Insert(const rocksdb::Slice& key, + rocksdb::Cache::ObjectPtr value, + const rocksdb::Cache::CacheItemHelper* helper, + size_t charge, rocksdb::Cache::Handle** handle, Priority priority) { uint32_t hash = HashSlice(key); return GetShard(Shard(hash)) - ->Insert(key, hash, value, charge, deleter, handle, priority); + ->Insert(key, hash, value, helper, charge, handle, priority); } -rocksdb::Cache::Handle* ShardedCache::Lookup(const rocksdb::Slice& key, rocksdb::Statistics* /*stats*/) { +rocksdb::Cache::Handle* ShardedCache::Lookup(const rocksdb::Slice& key, + const rocksdb::Cache::CacheItemHelper* /*helper*/, + rocksdb::Cache::CreateContext* /*create_context*/, + Priority /*priority*/, bool /*wait*/, + rocksdb::Statistics* /*stats*/) { + // Secondary cache is not supported by BinnedLRUCache, so the helper, + // create_context, priority, wait, and stats arguments are ignored here. uint32_t hash = HashSlice(key); return GetShard(Shard(hash))->Lookup(key, hash); } @@ -109,36 +117,25 @@ size_t ShardedCache::GetPinnedUsage() const { return usage; } -#if (ROCKSDB_MAJOR >= 7 || (ROCKSDB_MAJOR == 6 && ROCKSDB_MINOR >= 22)) -DeleterFn ShardedCache::GetDeleter(Handle* handle) const +const rocksdb::Cache::CacheItemHelper* ShardedCache::GetCacheItemHelper( + Handle* handle) const { uint32_t hash = GetHash(handle); - return GetShard(Shard(hash))->GetDeleter(handle); + return GetShard(Shard(hash))->GetCacheItemHelper(handle); } void ShardedCache::ApplyToAllEntries( - const std::function& callback, - const ApplyToAllEntriesOptions& opts) + const std::function& callback, + const ApplyToAllEntriesOptions& /*opts*/) { int num_shards = 1 << num_shard_bits_; for (int s = 0; s < num_shards; s++) { GetShard(s)->ApplyToAllCacheEntries(callback, true /* thread_safe */); } } -#else -void ShardedCache::ApplyToAllCacheEntries(void (*callback)(void*, size_t), - bool thread_safe) { - int num_shards = 1 << num_shard_bits_; - for (int s = 0; s < num_shards; s++) { - GetShard(s)->ApplyToAllCacheEntries( - [callback](const rocksdb::Slice&, void* value, size_t charge, DeleterFn) { - callback(value, charge); - }, - thread_safe); - } -} -#endif void ShardedCache::EraseUnRefEntries() { int num_shards = 1 << num_shard_bits_; diff --git a/src/kv/rocksdb_cache/ShardedCache.h b/src/kv/rocksdb_cache/ShardedCache.h index 63a56c4577e..8a78ddbf08d 100644 --- a/src/kv/rocksdb_cache/ShardedCache.h +++ b/src/kv/rocksdb_cache/ShardedCache.h @@ -26,18 +26,18 @@ namespace rocksdb_cache { -using DeleterFn = void (*)(const rocksdb::Slice& key, void* value); - // Single cache shard interface. class CacheShard { public: CacheShard() = default; virtual ~CacheShard() = default; - virtual rocksdb::Status Insert(const rocksdb::Slice& key, uint32_t hash, void* value, + virtual rocksdb::Status Insert(const rocksdb::Slice& key, uint32_t hash, + rocksdb::Cache::ObjectPtr value, + const rocksdb::Cache::CacheItemHelper* helper, size_t charge, - DeleterFn deleter, - rocksdb::Cache::Handle** handle, rocksdb::Cache::Priority priority) = 0; + rocksdb::Cache::Handle** handle, + rocksdb::Cache::Priority priority) = 0; virtual rocksdb::Cache::Handle* Lookup(const rocksdb::Slice& key, uint32_t hash) = 0; virtual bool Ref(rocksdb::Cache::Handle* handle) = 0; virtual bool Release(rocksdb::Cache::Handle* handle, bool force_erase = false) = 0; @@ -48,13 +48,14 @@ class CacheShard { virtual size_t GetPinnedUsage() const = 0; virtual void ApplyToAllCacheEntries( const std::function& callback, + const rocksdb::Cache::CacheItemHelper* helper)>& callback, bool thread_safe) = 0; virtual void EraseUnRefEntries() = 0; virtual std::string GetPrintableOptions() const { return ""; } - virtual DeleterFn GetDeleter(rocksdb::Cache::Handle* handle) const = 0; + virtual const rocksdb::Cache::CacheItemHelper* GetCacheItemHelper( + rocksdb::Cache::Handle* handle) const = 0; }; // Generic cache interface which shards cache by hash of keys. 2^num_shard_bits @@ -67,15 +68,22 @@ class ShardedCache : public rocksdb::Cache, public PriorityCache::PriCache { // rocksdb::Cache virtual const char* Name() const override = 0; using rocksdb::Cache::Insert; - virtual rocksdb::Status Insert(const rocksdb::Slice& key, void* value, size_t charge, - DeleterFn, - rocksdb::Cache::Handle** handle, Priority priority) override; + virtual rocksdb::Status Insert(const rocksdb::Slice& key, + rocksdb::Cache::ObjectPtr value, + const rocksdb::Cache::CacheItemHelper* helper, + size_t charge, + rocksdb::Cache::Handle** handle, + Priority priority) override; using rocksdb::Cache::Lookup; - virtual rocksdb::Cache::Handle* Lookup(const rocksdb::Slice& key, rocksdb::Statistics* stats) override; + virtual rocksdb::Cache::Handle* Lookup(const rocksdb::Slice& key, + const rocksdb::Cache::CacheItemHelper* helper, + rocksdb::Cache::CreateContext* create_context, + Priority priority, bool wait, + rocksdb::Statistics* stats) override; virtual bool Ref(rocksdb::Cache::Handle* handle) override; using rocksdb::Cache::Release; virtual bool Release(rocksdb::Cache::Handle* handle, bool force_erase = false) override; - virtual void* Value(Handle* handle) override = 0; + virtual rocksdb::Cache::ObjectPtr Value(Handle* handle) override = 0; virtual void Erase(const rocksdb::Slice& key) override; virtual uint64_t NewId() override; virtual void SetCapacity(size_t capacity) override; @@ -85,20 +93,16 @@ class ShardedCache : public rocksdb::Cache, public PriorityCache::PriCache { virtual size_t GetUsage() const override; virtual size_t GetUsage(rocksdb::Cache::Handle* handle) const override; virtual size_t GetPinnedUsage() const override; - virtual size_t GetCharge(Handle* handle) const = 0; -#if (ROCKSDB_MAJOR >= 7 || (ROCKSDB_MAJOR == 6 && ROCKSDB_MINOR >= 22)) - virtual DeleterFn GetDeleter(Handle* handle) const override; -#endif + virtual size_t GetCharge(Handle* handle) const override = 0; + virtual const rocksdb::Cache::CacheItemHelper* GetCacheItemHelper( + Handle* handle) const override; virtual void DisownData() override = 0; -#if (ROCKSDB_MAJOR >= 7 || (ROCKSDB_MAJOR == 6 && ROCKSDB_MINOR >= 22)) virtual void ApplyToAllEntries( - const std::function& callback, + const std::function& callback, const ApplyToAllEntriesOptions& opts) override; -#else - virtual void ApplyToAllCacheEntries(void (*callback)(void*, size_t), - bool thread_safe) override; -#endif virtual void EraseUnRefEntries() override; virtual std::string GetPrintableOptions() const override; virtual CacheShard* GetShard(int shard) = 0; diff --git a/src/rocksdb b/src/rocksdb index 24ea35870fe..0bd97e703ec 160000 --- a/src/rocksdb +++ b/src/rocksdb @@ -1 +1 @@ -Subproject commit 24ea35870fe9b3ba15285ec8746ba97ed5d67ff3 +Subproject commit 0bd97e703ec62ad602717482508b08dd91baa8f5 From dada074b3437b2696479496b93ab50f9da598662 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 29 May 2026 14:48:07 +0800 Subject: [PATCH 069/596] monitoring/ceph-mixin: bump jsonnet-bundler to v0.6.0 v0.4.0 fails to build on riscv64; bump to v0.6.0 to fix it. Signed-off-by: Sun Yuechi --- monitoring/ceph-mixin/jsonnet-bundler-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monitoring/ceph-mixin/jsonnet-bundler-build.sh b/monitoring/ceph-mixin/jsonnet-bundler-build.sh index d713cffb8bc..463658f186b 100755 --- a/monitoring/ceph-mixin/jsonnet-bundler-build.sh +++ b/monitoring/ceph-mixin/jsonnet-bundler-build.sh @@ -1,6 +1,6 @@ #!/bin/sh -ex -JSONNET_VERSION="v0.4.0" +JSONNET_VERSION="v0.6.0" OUTPUT_DIR=${1:-$(pwd)} git clone -b ${JSONNET_VERSION} --depth 1 https://github.com/jsonnet-bundler/jsonnet-bundler From 5a83c603876ad5b1fd8bd71044b624e9819a7832 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 29 May 2026 16:15:27 +0800 Subject: [PATCH 070/596] test/mds: don't drop await_idle_v under NDEBUG await_idle_v() is wrapped in assert(), so with NDEBUG it is never called and the test reads a default-constructed ack, racing the agent thread. Use EXPECT_TRUE() like the rest of the file. Signed-off-by: Sun Yuechi --- src/test/mds/TestQuiesceAgent.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/mds/TestQuiesceAgent.cc b/src/test/mds/TestQuiesceAgent.cc index aa108075faa..7940d8a5004 100644 --- a/src/test/mds/TestQuiesceAgent.cc +++ b/src/test/mds/TestQuiesceAgent.cc @@ -192,7 +192,7 @@ class QuiesceAgentTest : public testing::Test { if (WaitForAgent::No == wait) { return std::nullopt; } else { - assert(await_idle_v(v.set_version)); + EXPECT_TRUE(await_idle_v(v.set_version)); return async_ack; } } From 4e4dd6b1cdfcccf57a2e3a1372dd640f33d56b26 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 29 May 2026 16:46:13 +0800 Subject: [PATCH 071/596] cephadm/tests: mock find_program in test_container_engine test_container_engine mocks find_executable to keep command_check_host off the real host, but command_check_host looks up 'lvcreate' (and 'systemctl') through find_program, which isn't mocked. So the test actually needs lvcreate installed and only passes where it is. Mock find_program too so the test doesn't depend on host binaries. Signed-off-by: Sun Yuechi --- src/cephadm/tests/test_cephadm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index 7d2402d8b9b..27d4ff38252 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -2282,10 +2282,11 @@ exec /usr/bin/docker run --rm --ipc=host --stop-signal=SIGTERM --ulimit nofile=1 class TestCheckHost: + @mock.patch('cephadm.find_program', return_value='foo') @mock.patch('cephadm.find_executable', return_value='foo') @mock.patch('cephadm.check_time_sync', return_value=True) @mock.patch('cephadm.logger') - def test_container_engine(self, _logger, _find_executable, _check_time_sync): + def test_container_engine(self, _logger, _find_executable, _check_time_sync, _find_program): ctx = _cephadm.CephadmContext() ctx.container_engine = None From 84040d88296c03e0aeaca735946aa479ed4aa7ad Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Fri, 29 May 2026 13:44:45 -0400 Subject: [PATCH 072/596] qa/rgw/multisite: use random aes algorithm test coverage for `rgw crypt sse algorithm: aes-256-gcm` was recently added to the multisite suite, doubling the number of jobs from 2 to 4 instead of duplicating the multisite jobs for both cbc and gcm, select a random algorithm for each of the 2 existing jobs Signed-off-by: Casey Bodley --- qa/suites/rgw/multisite/{aes => aes$}/.qa | 0 qa/suites/rgw/multisite/{aes => aes$}/default.yaml | 0 qa/suites/rgw/multisite/{aes => aes$}/gcm.yaml | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename qa/suites/rgw/multisite/{aes => aes$}/.qa (100%) rename qa/suites/rgw/multisite/{aes => aes$}/default.yaml (100%) rename qa/suites/rgw/multisite/{aes => aes$}/gcm.yaml (100%) diff --git a/qa/suites/rgw/multisite/aes/.qa b/qa/suites/rgw/multisite/aes$/.qa similarity index 100% rename from qa/suites/rgw/multisite/aes/.qa rename to qa/suites/rgw/multisite/aes$/.qa diff --git a/qa/suites/rgw/multisite/aes/default.yaml b/qa/suites/rgw/multisite/aes$/default.yaml similarity index 100% rename from qa/suites/rgw/multisite/aes/default.yaml rename to qa/suites/rgw/multisite/aes$/default.yaml diff --git a/qa/suites/rgw/multisite/aes/gcm.yaml b/qa/suites/rgw/multisite/aes$/gcm.yaml similarity index 100% rename from qa/suites/rgw/multisite/aes/gcm.yaml rename to qa/suites/rgw/multisite/aes$/gcm.yaml From d69477cd02a21809b0a6f1a7211a9e5791cc92c1 Mon Sep 17 00:00:00 2001 From: "Ashwin M. Joshi" Date: Wed, 18 Feb 2026 11:19:12 +0530 Subject: [PATCH 073/596] python-common: Improve profile name string validation Fixes: https://tracker.ceph.com/issues/74986 Signed-off-by: Ashwin M. Joshi src/python-common/ceph/tests/test_service_spec.py Conflicts: src/python-common/ceph/deployment/service_spec.py --- .../ceph/deployment/service_spec.py | 19 ++++---- .../ceph/tests/test_service_spec.py | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 21ab7a04e75..86bdf18451a 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -52,6 +52,11 @@ ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec') FuncT = TypeVar('FuncT', bound=Callable) +def validate_non_empty_string(value: Optional[str], field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise SpecValidationError(f"Invalid {field_name}: Must be a non-empty string.") + + class TLSBlock(TypedDict, total=False): ssl: bool certificate_source: str @@ -2805,6 +2810,7 @@ class OAuth2ProxySpec(ServiceSpec): def validate(self) -> None: super(OAuth2ProxySpec, self).validate() + required_values = { 'provider_display_name': self.provider_display_name, 'oidc_issuer_url': self.oidc_issuer_url, @@ -2821,9 +2827,9 @@ class OAuth2ProxySpec(ServiceSpec): + ', '.join(missing_required_fields) + '.' ) - self._validate_non_empty_string(self.provider_display_name, "provider_display_name") - self._validate_non_empty_string(self.client_id, "client_id") - self._validate_non_empty_string(self.client_secret, "client_secret") + validate_non_empty_string(self.provider_display_name, "provider_display_name") + validate_non_empty_string(self.client_id, "client_id") + validate_non_empty_string(self.client_secret, "client_secret") self._validate_cookie_secret(self.cookie_secret) self._validate_url(self.oidc_issuer_url, "oidc_issuer_url") if self.redirect_url is not None: @@ -2835,10 +2841,6 @@ class OAuth2ProxySpec(ServiceSpec): if self.https_address is not None: self._validate_https_address(self.https_address) - def _validate_non_empty_string(self, value: Optional[str], field_name: str) -> None: - if not value or not isinstance(value, str) or not value.strip(): - raise SpecValidationError(f"Invalid {field_name}: Must be a non-empty string.") - def _validate_url(self, url: Optional[str], field_name: str) -> None: from urllib.parse import urlparse try: @@ -3655,8 +3657,7 @@ class TunedProfileSpec(): if 'profile_name' not in spec: raise SpecValidationError('Tuned profile spec must include "profile_name" field') data['profile_name'] = spec['profile_name'] - if not isinstance(data['profile_name'], str): - raise SpecValidationError('"profile_name" field must be a string') + validate_non_empty_string(data['profile_name'], "profile_name") if 'placement' in spec: data['placement'] = PlacementSpec.from_json(spec['placement']) if 'settings' in spec: diff --git a/src/python-common/ceph/tests/test_service_spec.py b/src/python-common/ceph/tests/test_service_spec.py index 92b86216686..5d470f7804a 100644 --- a/src/python-common/ceph/tests/test_service_spec.py +++ b/src/python-common/ceph/tests/test_service_spec.py @@ -21,6 +21,7 @@ from ceph.deployment.service_spec import ( RGWSpec, ServiceSpec, YamlLiteralString, + TunedProfileSpec, ) from ceph.deployment.drive_group import DriveGroupSpec from ceph.deployment.hostspec import SpecValidationError @@ -1592,3 +1593,49 @@ spec: assert 'ssl_cert: |' in dumped assert 'ssl_key: |' in dumped + +# Tuned profile spec (e.g. ceph orch tuned-profile apply -i os-tune.spec) +VALID_TUNED_PROFILE_SPEC = """ +profile_name: os-tune +placement: + hosts: + - ceph-node-0 + - ceph-node-1 + - ceph-node-2 +""" + +EMPTY_PROFILE_NAME_SPEC = """ +profile_name: '' +placement: + hosts: + - ceph-node-0 + - ceph-node-1 + - ceph-node-2 +""" + +MISSING_PROFILE_NAME_SPEC = """ +placement: + hosts: + - ceph-node-0 + - ceph-node-1 + - ceph-node-2 +""" + + +@pytest.mark.parametrize("spec_yaml, expect_error, error_match", [ + (EMPTY_PROFILE_NAME_SPEC, True, r'Invalid profile_name: Must be a non-empty string\.'), + (MISSING_PROFILE_NAME_SPEC, True, r'Tuned profile spec must include "profile_name" field'), + (VALID_TUNED_PROFILE_SPEC, False, None), +]) +def test_tuned_profile_spec_profile_name_validation(spec_yaml, expect_error, error_match): + """Test TunedProfileSpec.from_json validation for profile_name (ceph orch tuned-profile apply -i ).""" + data = yaml.safe_load(spec_yaml) + if expect_error: + with pytest.raises(SpecValidationError, match=error_match): + TunedProfileSpec.from_json(data) + else: + spec = TunedProfileSpec.from_json(data) + assert spec.profile_name == 'os-tune' + assert spec.placement is not None + # round-trip + assert TunedProfileSpec.from_json(spec.to_json()).profile_name == spec.profile_name From 6260b33f1d226b6925f6242f5eeae5568619b411 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Tue, 2 Jun 2026 16:33:17 +0200 Subject: [PATCH 074/596] mgr/cephadm: Don't skip OSDs with non-empty osdspec_affinity `ceph cephadm osd activate` calls `deploy_osd_daemons_for_existing_osds` with a synthetic DriveGroupSpec where service_id=''. Commit fbe3a053 introduced an unconditional osdspec_affinity filter: if osd['tags']['ceph.osdspec_affinity'] != spec.service_id: continue The fix is to only enforce the affinity check when spec.service_id is non-empty. An empty service_id means the caller is osd activate, which should adopt any existing OSD regardless of its affinity tag. Fixes: https://tracker.ceph.com/issues/76979 Signed-off-by: Redouane Kachach --- src/pybind/mgr/cephadm/services/osd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/cephadm/services/osd.py b/src/pybind/mgr/cephadm/services/osd.py index a3d116d3c87..e2ca9a23835 100644 --- a/src/pybind/mgr/cephadm/services/osd.py +++ b/src/pybind/mgr/cephadm/services/osd.py @@ -146,7 +146,7 @@ class OSDService(CephService): if osd['tags']['ceph.cluster_fsid'] != fsid: logger.debug('mismatched fsid, skipping %s' % osd) continue - if osd['tags']['ceph.osdspec_affinity'] != spec.service_id: + if spec.service_id and osd['tags']['ceph.osdspec_affinity'] != spec.service_id: logger.debug('mismatched service id, skipping %s' % osd) continue if osd_id in before_osd_uuid_map and osd_id not in replace_osd_ids: From b09f161916e696d2cb50401d4a486b8cdca20124 Mon Sep 17 00:00:00 2001 From: Kobi Ginon Date: Sun, 31 May 2026 20:22:24 +0300 Subject: [PATCH 075/596] cephadm: start rpcbind before NFS-Ganesha when NFSv3 is enabled With Protocols including NFSv3, Ganesha 9.x on Rocky 10 needs a running portmapper to register NFSv3 on TCP. Start rpcbind from the container entrypoint only when ganesha.conf enables v3; skip for NFSv4-only configs. Fixes: https://tracker.ceph.com/issues/76981 Signed-off-by: Kobi Ginon --- src/cephadm/cephadmlib/daemons/nfs.py | 52 ++++++++++++++++++- src/cephadm/tests/test_nfs.py | 73 +++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/cephadm/cephadmlib/daemons/nfs.py b/src/cephadm/cephadmlib/daemons/nfs.py index 1783951a71f..61c30868135 100644 --- a/src/cephadm/cephadmlib/daemons/nfs.py +++ b/src/cephadm/cephadmlib/daemons/nfs.py @@ -28,7 +28,9 @@ class NFSGanesha(ContainerDaemonForm): """Defines a NFS-Ganesha container""" daemon_type = 'nfs' - entrypoint = '/usr/bin/ganesha.nfsd' + entrypoint = '/usr/local/scripts/ganesha-entrypoint.sh' + ganesha_binary = '/usr/bin/ganesha.nfsd' + entrypoint_script_name = 'ganesha-entrypoint.sh' daemon_args = ['-F', '-L', 'STDERR'] required_files = ['ganesha.conf', 'idmap.conf'] @@ -88,6 +90,9 @@ class NFSGanesha(ContainerDaemonForm): mounts[os.path.join(data_dir, 'config')] = '/etc/ceph/ceph.conf:z' mounts[os.path.join(data_dir, 'keyring')] = '/etc/ceph/keyring:z' mounts[os.path.join(data_dir, 'etc/ganesha')] = '/etc/ganesha:z' + mounts[ + os.path.join(data_dir, self.entrypoint_script_name) + ] = self.entrypoint if self.rgw: cluster = self.rgw.get('cluster', 'ceph') rgw_user = self.rgw.get('user', 'admin') @@ -118,7 +123,7 @@ class NFSGanesha(ContainerDaemonForm): ctx.container_engine.path, 'exec', container_id, - NFSGanesha.entrypoint, + NFSGanesha.ganesha_binary, '-v', ], verbosity=CallVerbosity.QUIET, @@ -168,6 +173,42 @@ class NFSGanesha(ContainerDaemonForm): # type: () -> List[str] return self.daemon_args + self.extra_args + @staticmethod + def ganesha_conf_text(conf: Union[str, List[str]]) -> str: + if isinstance(conf, list): + return '\n'.join(conf) + return conf + + @staticmethod + def nfsv3_enabled_in_ganesha_conf(conf: Union[str, List[str]]) -> bool: + """Return True when ganesha.conf enables NFSv3 (Protocols includes 3).""" + text = NFSGanesha.ganesha_conf_text(conf) + match = re.search(r'Protocols\s*=\s*([^;]+)', text, re.MULTILINE) + if not match: + # No Protocols line (e.g. minimal test stubs): keep legacy behavior. + return True + protocols = match.group(1) + return bool(re.search(r'(?:^|[,\s])3(?:[,\s]|$)', protocols)) + + @staticmethod + def ganesha_entrypoint_script(nfsv3: bool = True) -> str: + # NFSv3 registration with SunRPC requires a running portmapper. + # The image installs rpcbind but cephadm previously started only + # ganesha.nfsd, which fails on Rocky 10 / Ganesha 9.x with + # "Cannot register NFS V3 on TCP" when rpcbind is not up. + rpcbind_block = '' + if nfsv3: + rpcbind_block = """if command -v rpcbind >/dev/null 2>&1; then + if ! rpcinfo -p >/dev/null 2>&1; then + rpcbind + fi +fi +""" + return f"""#!/bin/bash +set -e +{rpcbind_block}exec /usr/bin/ganesha.nfsd "$@" +""" + def create_daemon_dirs(self, data_dir, uid, gid): # type: (str, int, int) -> None """Create files under the container data dir""" @@ -196,6 +237,13 @@ class NFSGanesha(ContainerDaemonForm): populate_files(config_dir, config_files, uid, gid) populate_files(tls_dir, tls_files, uid, gid) + ganesha_conf = self.files.get('ganesha.conf', '') + nfsv3 = self.nfsv3_enabled_in_ganesha_conf(ganesha_conf) + entrypoint_path = os.path.join(data_dir, self.entrypoint_script_name) + with write_new(entrypoint_path, owner=(uid, gid)) as f: + f.write(self.ganesha_entrypoint_script(nfsv3=nfsv3)) + os.chmod(entrypoint_path, 0o700) + # write the RGW keyring if self.rgw: keyring_path = os.path.join(data_dir, 'keyring.rgw') diff --git a/src/cephadm/tests/test_nfs.py b/src/cephadm/tests/test_nfs.py index 1b468516e67..8c0676482cc 100644 --- a/src/cephadm/tests/test_nfs.py +++ b/src/cephadm/tests/test_nfs.py @@ -119,10 +119,14 @@ def test_nfsganesha_container_mounts(): good_nfs_json(), ) cmounts = nfsg._get_container_mounts("/var/tmp") - assert len(cmounts) == 3 + assert len(cmounts) == 4 assert cmounts["/var/tmp/config"] == "/etc/ceph/ceph.conf:z" assert cmounts["/var/tmp/keyring"] == "/etc/ceph/keyring:z" assert cmounts["/var/tmp/etc/ganesha"] == "/etc/ganesha:z" + assert ( + cmounts["/var/tmp/ganesha-entrypoint.sh"] + == "/usr/local/scripts/ganesha-entrypoint.sh" + ) with with_cephadm_ctx([]) as ctx: nfsg = _cephadm.NFSGanesha( @@ -132,7 +136,7 @@ def test_nfsganesha_container_mounts(): nfs_json(pool=True, files=True, rgw=True), ) cmounts = nfsg._get_container_mounts("/var/tmp") - assert len(cmounts) == 4 + assert len(cmounts) == 5 assert cmounts["/var/tmp/config"] == "/etc/ceph/ceph.conf:z" assert cmounts["/var/tmp/keyring"] == "/etc/ceph/keyring:z" assert cmounts["/var/tmp/etc/ganesha"] == "/etc/ganesha:z" @@ -212,6 +216,52 @@ def test_nfsganesha_get_daemon_args(): assert args == ["-F", "-L", "STDERR"] +@pytest.mark.parametrize( + 'conf,expected', + [ + ('Protocols = 3, 4;', True), + ('Protocols = 4, 3;', True), + ('Protocols = 4;', False), + ('Protocols = 4, nfsrdma, rpcrdma;', False), + ('', True), + ], +) +def test_nfsv3_enabled_in_ganesha_conf(conf, expected): + assert _cephadm.NFSGanesha.nfsv3_enabled_in_ganesha_conf(conf) is expected + + +def test_nfsganesha_entrypoint_script_nfsv3(): + script = _cephadm.NFSGanesha.ganesha_entrypoint_script(nfsv3=True) + assert 'rpcbind' in script + assert 'exec /usr/bin/ganesha.nfsd "$@"' in script + + +def test_nfsganesha_entrypoint_script_v4_only(): + script = _cephadm.NFSGanesha.ganesha_entrypoint_script(nfsv3=False) + assert 'rpcbind' not in script + assert 'exec /usr/bin/ganesha.nfsd "$@"' in script + + +def test_nfsganesha_entrypoint_script_from_conf(): + conf_v3 = '\n'.join( + [ + 'NFS_CORE_PARAM {', + ' Protocols = 3, 4;', + '}', + ] + ) + script = _cephadm.NFSGanesha.ganesha_entrypoint_script( + nfsv3=_cephadm.NFSGanesha.nfsv3_enabled_in_ganesha_conf(conf_v3), + ) + assert 'rpcbind' in script + + conf_v4 = 'NFS_CORE_PARAM { Protocols = 4; }' + script = _cephadm.NFSGanesha.ganesha_entrypoint_script( + nfsv3=_cephadm.NFSGanesha.nfsv3_enabled_in_ganesha_conf(conf_v4), + ) + assert 'rpcbind' not in script + + @mock.patch("cephadm.logger") def test_nfsganesha_create_daemon_dirs(_logger, cephadm_fs): with with_cephadm_ctx([]) as ctx: @@ -225,7 +275,24 @@ def test_nfsganesha_create_daemon_dirs(_logger, cephadm_fs): nfsg.create_daemon_dirs("/var/tmp", 45, 54) cephadm_fs.create_dir("/var/tmp") nfsg.create_daemon_dirs("/var/tmp", 45, 54) - # TODO: make assertions about the dirs created + with open("/var/tmp/ganesha-entrypoint.sh") as f: + assert 'rpcbind' in f.read() + + nfsg_v4 = _cephadm.NFSGanesha( + ctx, + SAMPLE_UUID, + "fred", + { + 'pool': 'party', + 'files': { + 'ganesha.conf': 'NFS_CORE_PARAM { Protocols = 4; }', + 'idmap.conf': '', + }, + }, + ) + nfsg_v4.create_daemon_dirs("/var/tmp", 45, 54) + with open("/var/tmp/ganesha-entrypoint.sh") as f: + assert 'rpcbind' not in f.read() @mock.patch("cephadm.logger") From 2690e2a2cb8a98625be35127f24a57a89f0692f1 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 21 Jan 2026 20:11:29 +0000 Subject: [PATCH 076/596] rgw/http: introduce RGWEndpoint to carry url + connect_to (refactor) This commit is meant to be non-functional. Replace the "URL as plain string" plumbing with an RGWEndpoint value type that carries: - the URL string, and - optional per-request connect_to data for libcurl, - it also encapsulates related functions/methods This commit is intended to be non-functional: behavior should remain unchanged until callers populate connect_to. Signed-off-by: Oguzhan Ozmen --- src/rgw/driver/rados/rgw_sal_rados.cc | 2 +- src/rgw/rgw_http_client.cc | 17 ++-- src/rgw/rgw_http_client.h | 66 ++++++++++++---- src/rgw/rgw_keystone.h | 4 +- src/rgw/rgw_opa.cc | 2 +- src/rgw/rgw_rest_client.cc | 43 +++++----- src/rgw/rgw_rest_client.h | 42 +++++----- src/rgw/rgw_rest_conn.cc | 109 ++++++++++++++------------ src/rgw/rgw_rest_conn.h | 14 ++-- src/rgw/services/svc_zone.cc | 6 +- src/rgw/services/svc_zone.h | 2 +- 11 files changed, 176 insertions(+), 131 deletions(-) diff --git a/src/rgw/driver/rados/rgw_sal_rados.cc b/src/rgw/driver/rados/rgw_sal_rados.cc index 841ac0a6ec0..1683fb71f1c 100644 --- a/src/rgw/driver/rados/rgw_sal_rados.cc +++ b/src/rgw/driver/rados/rgw_sal_rados.cc @@ -5398,7 +5398,7 @@ bool RadosZone::is_writeable() bool RadosZone::get_redirect_endpoint(std::string* endpoint) { if (local_zone) - return store->svc()->zone->get_redirect_zone_endpoint(endpoint); + return store->svc()->zone->get_redirect_zone_endpoint_url(endpoint); endpoint = &rgw_zone.redirect_zone; return true; diff --git a/src/rgw/rgw_http_client.cc b/src/rgw/rgw_http_client.cc index dc798943ee4..4cf35d09f26 100644 --- a/src/rgw/rgw_http_client.cc +++ b/src/rgw/rgw_http_client.cc @@ -294,7 +294,7 @@ void RGWIOProvider::assign_io(RGWIOIDProvider& io_id_provider, int io_type) RGWHTTPClient::RGWHTTPClient(CephContext *cct, const string& _method, - const string& _url) + const RGWEndpoint& _endpoint) : NoDoutPrefix(cct, dout_subsys), has_send_len(false), http_status(HTTP_STATUS_NOSTATUS), @@ -302,14 +302,14 @@ RGWHTTPClient::RGWHTTPClient(CephContext *cct, verify_ssl(cct->_conf->rgw_verify_ssl), cct(cct), method(_method), - url_orig(_url), - url(_url) { + endpoint_orig(_endpoint), + endpoint(_endpoint) { init(); } std::ostream& RGWHTTPClient::gen_prefix(std::ostream& out) const { - out << "http_client[" << method << "/" << url << "]"; + out << "http_client[" << method << "/" << endpoint.get_url() << "]"; return out; } @@ -326,6 +326,8 @@ void RGWHTTPClient::init() } } + const string& url = endpoint.get_url(); + auto pos = url.find("://"); if (pos == string::npos) { host = url; @@ -551,7 +553,7 @@ int RGWHTTPClient::process(const DoutPrefixProvider* dpp, optional_yield y) string RGWHTTPClient::to_str() { string method_str = (method.empty() ? "" : method); - string url_str = (url.empty() ? "" : url); + string url_str = (endpoint.get_url().empty() ? "" : endpoint.get_url()); return method_str + " " + url_str; } @@ -577,14 +579,15 @@ int RGWHTTPClient::init_request(rgw_http_req_data *_req_data) CURL *easy_handle = req_data->get_easy_handle(); - dout(20) << "sending request to " << url << dendl; + dout(20) << "sending request to url=" << endpoint.get_url() + << " connect_to=" << endpoint.get_connect_to() << dendl; curl_slist *h = headers_to_slist(headers); req_data->h = h; curl_easy_setopt(easy_handle, CURLOPT_CUSTOMREQUEST, method.c_str()); - curl_easy_setopt(easy_handle, CURLOPT_URL, url.c_str()); + curl_easy_setopt(easy_handle, CURLOPT_URL, endpoint.get_url().c_str()); curl_easy_setopt(easy_handle, CURLOPT_NOPROGRESS, 1L); curl_easy_setopt(easy_handle, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(easy_handle, CURLOPT_HEADERFUNCTION, receive_http_header); diff --git a/src/rgw/rgw_http_client.h b/src/rgw/rgw_http_client.h index 37b3991297a..6eaf1844401 100644 --- a/src/rgw/rgw_http_client.h +++ b/src/rgw/rgw_http_client.h @@ -20,6 +20,41 @@ void rgw_http_client_cleanup(); struct rgw_http_req_data; class RGWHTTPManager; +struct RGWEndpoint { +private: + std::string url; + std::string connect_to; + +public: + RGWEndpoint() = default; + + RGWEndpoint(const char* u) : RGWEndpoint(std::string(u)) {} + + RGWEndpoint(std::string u, std::string c = {}) + : url(std::move(u)), connect_to(std::move(c)) {} + + RGWEndpoint with_url(std::string new_url) const { + RGWEndpoint e = *this; + e.set_url(std::move(new_url)); + return e; + } + + void set_url(const std::string& _url) { url = _url; } + const std::string& get_url() const { return url; } + + void set_connect_to(const std::string& _connect_to) { connect_to = _connect_to; } + const std::string& get_connect_to() const { return connect_to; } + + void add_trailing_slash() { + if (! url.empty() && url.back() != '/') + append_to_url("/"); + } + + void append_to_url(const std::string& suffix) { + url.append(suffix); + } +}; + class RGWHTTPClient : public RGWIOProvider, public NoDoutPrefix { @@ -47,13 +82,12 @@ class RGWHTTPClient : public RGWIOProvider, std::atomic stopped { 0 }; - protected: CephContext *cct; std::string method; - std::string url_orig; - std::string url; + RGWEndpoint endpoint_orig; + RGWEndpoint endpoint; std::string protocol; std::string host; @@ -116,7 +150,7 @@ public: virtual ~RGWHTTPClient(); explicit RGWHTTPClient(CephContext *cct, const std::string& _method, - const std::string& _url); + const RGWEndpoint& _endpoint); std::ostream& gen_prefix(std::ostream& out) const override; @@ -170,12 +204,16 @@ public: int get_req_retcode(); - void set_url(const std::string& _url) { - url = _url; + void set_endpoint(const RGWEndpoint& _endpoint) { + endpoint = _endpoint; } - const std::string& get_url_orig() const { - return url_orig; + void set_url(const std::string& _url) { + endpoint.set_url(_url); + } + + const RGWEndpoint& get_endpoint_orig() const { + return endpoint_orig; } void set_method(const std::string& _method) { @@ -212,9 +250,9 @@ public: RGWHTTPHeadersCollector(CephContext * const cct, const std::string& method, - const std::string& url, + const RGWEndpoint& endpoint, const header_spec_t &relevant_headers) - : RGWHTTPClient(cct, method, url), + : RGWHTTPClient(cct, method, endpoint), relevant_headers(relevant_headers) { } @@ -244,21 +282,21 @@ class RGWHTTPTransceiver : public RGWHTTPHeadersCollector { public: RGWHTTPTransceiver(CephContext * const cct, const std::string& method, - const std::string& url, + const RGWEndpoint& endpoint, bufferlist * const read_bl, const header_spec_t intercept_headers = {}) - : RGWHTTPHeadersCollector(cct, method, url, intercept_headers), + : RGWHTTPHeadersCollector(cct, method, endpoint, intercept_headers), read_bl(read_bl), post_data_index(0) { } RGWHTTPTransceiver(CephContext * const cct, const std::string& method, - const std::string& url, + const RGWEndpoint& endpoint, bufferlist * const read_bl, const bool verify_ssl, const header_spec_t intercept_headers = {}) - : RGWHTTPHeadersCollector(cct, method, url, intercept_headers), + : RGWHTTPHeadersCollector(cct, method, endpoint, intercept_headers), read_bl(read_bl), post_data_index(0) { set_verify_ssl(verify_ssl); diff --git a/src/rgw/rgw_keystone.h b/src/rgw/rgw_keystone.h index 2dc17dfeabe..e183b9d3965 100644 --- a/src/rgw/rgw_keystone.h +++ b/src/rgw/rgw_keystone.h @@ -89,9 +89,9 @@ public: public: RGWKeystoneHTTPTransceiver(CephContext * const cct, const std::string& method, - const std::string& url, + const RGWEndpoint& endpoint, bufferlist * const token_body_bl) - : RGWHTTPTransceiver(cct, method, url, token_body_bl, + : RGWHTTPTransceiver(cct, method, endpoint, token_body_bl, cct->_conf->rgw_keystone_verify_ssl, { "X-Subject-Token" }) { } diff --git a/src/rgw/rgw_opa.cc b/src/rgw/rgw_opa.cc index c096fd0bd37..6840227cffe 100644 --- a/src/rgw/rgw_opa.cc +++ b/src/rgw/rgw_opa.cc @@ -28,7 +28,7 @@ int rgw_opa_authorize(RGWOp *& op, int ret; bufferlist bl; - RGWHTTPTransceiver req(s->cct, "POST", opa_url.c_str(), &bl); + RGWHTTPTransceiver req(s->cct, "POST", opa_url, &bl); /* set required headers for OPA request */ req.append_header("X-Auth-Token", opa_token); diff --git a/src/rgw/rgw_rest_client.cc b/src/rgw/rgw_rest_client.cc index 8cd692aa9dc..9317587ac78 100644 --- a/src/rgw/rgw_rest_client.cc +++ b/src/rgw/rgw_rest_client.cc @@ -425,7 +425,7 @@ auto RGWRESTSimpleRequest::forward_request(const DoutPrefixProvider *dpp, const string params_str; get_params_str(new_info.args.get_params(), params_str); - string new_url = url; + string new_url = endpoint.get_url(); string& resource = new_info.request_uri; string new_resource = resource; if (new_url[new_url.size() - 1] == '/' && resource[0] == '/') { @@ -446,7 +446,7 @@ auto RGWRESTSimpleRequest::forward_request(const DoutPrefixProvider *dpp, const } method = new_info.method; - url = new_url; + endpoint.set_url(new_url); std::ignore = process(dpp, y); @@ -564,7 +564,7 @@ RGWRESTGenerateHTTPHeaders::RGWRESTGenerateHTTPHeaders(CephContext *_cct, RGWEnv } void RGWRESTGenerateHTTPHeaders::init(const string& _method, const string& host, - const string& resource_prefix, const string& _url, + const string& resource_prefix, const RGWEndpoint& _endpoint, const string& resource, const param_vec_t& params, std::optional api_name) { @@ -581,7 +581,7 @@ void RGWRESTGenerateHTTPHeaders::init(const string& _method, const string& host, url_encode(iter->second, encode_slash)); } - url = _url + resource + params_str; + endpoint = _endpoint.with_url(_endpoint.get_url() + resource + params_str); const std::string date_str = get_gmt_date_str(); new_env->set("HTTP_DATE", date_str.c_str()); @@ -685,32 +685,29 @@ void RGWRESTStreamS3PutObj::send_init(const rgw_obj& obj) { string resource_str; string resource; - string new_url = url; + RGWEndpoint new_endpoint = endpoint; string new_host = host; const auto& bucket_name = obj.bucket.name; if (host_style == VirtualStyle) { resource_str = obj.get_oid(); - - new_url = protocol + "://" + bucket_name + "." + host; + new_endpoint.set_url(protocol + "://" + bucket_name + "." + host); new_host = bucket_name + "." + new_host; } else { resource_str = bucket_name + "/" + obj.get_oid(); } + new_endpoint.add_trailing_slash(); //do not encode slash in object key name url_encode(resource_str, resource, false); - if (new_url[new_url.size() - 1] != '/') - new_url.append("/"); - - ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_url << dendl; + ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_endpoint.get_url() << dendl; method = "PUT"; - headers_gen.init(method, new_host, resource_prefix, new_url, resource, params, api_name); + headers_gen.init(method, new_host, resource_prefix, new_endpoint, resource, params, api_name); - url = headers_gen.get_url(); + endpoint = headers_gen.get_endpoint(); } void RGWRESTStreamS3PutObj::send_ready(const DoutPrefixProvider *dpp, RGWAccessKey& key, map& rgw_attrs) @@ -825,10 +822,10 @@ int RGWRESTStreamRWRequest::send_prepare(const DoutPrefixProvider *dpp, RGWAcces int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAccessKey *key, map& extra_headers, const string& resource, bufferlist *send_data) { - string new_url = url; - if (!new_url.empty() && new_url.back() != '/') - new_url.append("/"); - + RGWEndpoint new_endpoint = endpoint; + + new_endpoint.add_trailing_slash(); + string new_resource; string bucket_name; string old_resource = resource; @@ -849,7 +846,7 @@ int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAc } if (host_style == VirtualStyle) { - new_url = protocol + "://" + bucket_name + "." + host; + new_endpoint.set_url(protocol + "://" + bucket_name + "." + host); if(pos == string::npos) { new_resource = ""; } else { @@ -857,15 +854,13 @@ int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAc } new_host = bucket_name + "." + host; } - - if (new_url[new_url.size() - 1] != '/') - new_url.append("/"); + new_endpoint.add_trailing_slash(); headers_gen.emplace(cct, &new_env, &new_info); - ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_url << " , new_resource = " << new_resource << dendl; + ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_endpoint.get_url() << " , new_resource = " << new_resource << dendl; - headers_gen->init(method, new_host, resource_prefix, new_url, new_resource, params, api_name); + headers_gen->init(method, new_host, resource_prefix, new_endpoint, new_resource, params, api_name); headers_gen->set_http_attrs(extra_headers); @@ -880,7 +875,7 @@ int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAc } method = new_info.method; - url = headers_gen->get_url(); + endpoint = headers_gen->get_endpoint(); return 0; } diff --git a/src/rgw/rgw_rest_client.h b/src/rgw/rgw_rest_client.h index 2d30e8313f6..e9458f44f7e 100644 --- a/src/rgw/rgw_rest_client.h +++ b/src/rgw/rgw_rest_client.h @@ -29,8 +29,8 @@ protected: void get_params_str(std::map& extra_args, std::string& dest); public: - RGWHTTPSimpleRequest(CephContext *_cct, const std::string& _method, const std::string& _url, - param_vec_t *_headers, param_vec_t *_params) : RGWHTTPClient(_cct, _method, _url), + RGWHTTPSimpleRequest(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, + param_vec_t *_headers, param_vec_t *_params) : RGWHTTPClient(_cct, _method, _endpoint), http_status(0), status(0), send_iter(NULL), max_response(0) { @@ -63,9 +63,9 @@ public: class RGWRESTSimpleRequest : public RGWHTTPSimpleRequest { std::optional api_name; public: - RGWRESTSimpleRequest(CephContext *_cct, const std::string& _method, const std::string& _url, + RGWRESTSimpleRequest(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, param_vec_t *_headers, param_vec_t *_params, - std::optional _api_name) : RGWHTTPSimpleRequest(_cct, _method, _url, _headers, _params), api_name(_api_name) {} + std::optional _api_name) : RGWHTTPSimpleRequest(_cct, _method, _endpoint, _headers, _params), api_name(_api_name) {} // return the http status of the response or an error code from the transport auto forward_request(const DoutPrefixProvider *dpp, const RGWAccessKey& key, const req_info& info, size_t max_response, bufferlist *inbl, bufferlist *outbl, optional_yield y, std::string service="") @@ -86,13 +86,13 @@ class RGWRESTGenerateHTTPHeaders : public DoutPrefix { std::string region; std::string service; std::string method; - std::string url; + RGWEndpoint endpoint; std::string resource; public: RGWRESTGenerateHTTPHeaders(CephContext *_cct, RGWEnv *_env, req_info *_info); void init(const std::string& method, const std::string& host, - const std::string& resource_prefix, const std::string& url, + const std::string& resource_prefix, const RGWEndpoint& endpoint, const std::string& resource, const param_vec_t& params, std::optional api_name); void set_extra_headers(const std::map& extra_headers); @@ -101,7 +101,7 @@ public: void set_policy(const RGWAccessControlPolicy& policy); int sign(const DoutPrefixProvider *dpp, RGWAccessKey& key, const bufferlist *opt_content); - const std::string& get_url() { return url; } + const RGWEndpoint& get_endpoint() { return endpoint; } }; class RGWHTTPStreamRWRequest : public RGWHTTPSimpleRequest { @@ -145,11 +145,11 @@ public: } }; - RGWHTTPStreamRWRequest(CephContext *_cct, const std::string& _method, const std::string& _url, - param_vec_t *_headers, param_vec_t *_params) : RGWHTTPSimpleRequest(_cct, _method, _url, _headers, _params) { + RGWHTTPStreamRWRequest(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, + param_vec_t *_headers, param_vec_t *_params) : RGWHTTPSimpleRequest(_cct, _method, _endpoint, _headers, _params) { } - RGWHTTPStreamRWRequest(CephContext *_cct, const std::string& _method, const std::string& _url, ReceiveCB *_cb, - param_vec_t *_headers, param_vec_t *_params) : RGWHTTPSimpleRequest(_cct, _method, _url, _headers, _params), + RGWHTTPStreamRWRequest(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, ReceiveCB *_cb, + param_vec_t *_headers, param_vec_t *_params) : RGWHTTPSimpleRequest(_cct, _method, _endpoint, _headers, _params), cb(_cb) { } virtual ~RGWHTTPStreamRWRequest() override {} @@ -192,10 +192,10 @@ protected: std::optional api_name; HostStyle host_style; public: - RGWRESTStreamRWRequest(CephContext *_cct, const std::string& _method, const std::string& _url, RGWHTTPStreamRWRequest::ReceiveCB *_cb, + RGWRESTStreamRWRequest(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, RGWHTTPStreamRWRequest::ReceiveCB *_cb, param_vec_t *_headers, param_vec_t *_params, std::optional _api_name, HostStyle _host_style = PathStyle) : - RGWHTTPStreamRWRequest(_cct, _method, _url, _cb, _headers, _params), + RGWHTTPStreamRWRequest(_cct, _method, _endpoint, _cb, _headers, _params), new_info(_cct, &new_env), api_name(_api_name), host_style(_host_style) { } @@ -216,24 +216,24 @@ private: class RGWRESTStreamReadRequest : public RGWRESTStreamRWRequest { public: - RGWRESTStreamReadRequest(CephContext *_cct, const std::string& _url, ReceiveCB *_cb, param_vec_t *_headers, + RGWRESTStreamReadRequest(CephContext *_cct, const RGWEndpoint& _endpoint, ReceiveCB *_cb, param_vec_t *_headers, param_vec_t *_params, std::optional _api_name, - HostStyle _host_style = PathStyle) : RGWRESTStreamRWRequest(_cct, "GET", _url, _cb, _headers, _params, _api_name, _host_style) {} + HostStyle _host_style = PathStyle) : RGWRESTStreamRWRequest(_cct, "GET", _endpoint, _cb, _headers, _params, _api_name, _host_style) {} }; class RGWRESTStreamHeadRequest : public RGWRESTStreamRWRequest { public: - RGWRESTStreamHeadRequest(CephContext *_cct, const std::string& _url, ReceiveCB *_cb, param_vec_t *_headers, - param_vec_t *_params, std::optional _api_name) : RGWRESTStreamRWRequest(_cct, "HEAD", _url, _cb, _headers, _params, _api_name) {} + RGWRESTStreamHeadRequest(CephContext *_cct, const RGWEndpoint& _endpoint, ReceiveCB *_cb, param_vec_t *_headers, + param_vec_t *_params, std::optional _api_name) : RGWRESTStreamRWRequest(_cct, "HEAD", _endpoint, _cb, _headers, _params, _api_name) {} }; class RGWRESTStreamSendRequest : public RGWRESTStreamRWRequest { public: RGWRESTStreamSendRequest(CephContext *_cct, const std::string& method, - const std::string& _url, + const RGWEndpoint& _endpoint, ReceiveCB *_cb, param_vec_t *_headers, param_vec_t *_params, std::optional _api_name, - HostStyle _host_style = PathStyle) : RGWRESTStreamRWRequest(_cct, method, _url, _cb, _headers, _params, _api_name, _host_style) {} + HostStyle _host_style = PathStyle) : RGWRESTStreamRWRequest(_cct, method, _endpoint, _cb, _headers, _params, _api_name, _host_style) {} }; class RGWRESTStreamS3PutObj : public RGWHTTPStreamRWRequest { @@ -244,9 +244,9 @@ class RGWRESTStreamS3PutObj : public RGWHTTPStreamRWRequest { req_info new_info; RGWRESTGenerateHTTPHeaders headers_gen; public: - RGWRESTStreamS3PutObj(CephContext *_cct, const std::string& _method, const std::string& _url, param_vec_t *_headers, + RGWRESTStreamS3PutObj(CephContext *_cct, const std::string& _method, const RGWEndpoint& _endpoint, param_vec_t *_headers, param_vec_t *_params, std::optional _api_name, - HostStyle _host_style) : RGWHTTPStreamRWRequest(_cct, _method, _url, nullptr, _headers, _params), + HostStyle _host_style) : RGWHTTPStreamRWRequest(_cct, _method, _endpoint, nullptr, _headers, _params), api_name(_api_name), host_style(_host_style), out_cb(NULL), new_info(cct, &new_env), headers_gen(_cct, &new_env, &new_info) {} ~RGWRESTStreamS3PutObj() override; diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index aab716e47a4..0f32cc201f1 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -78,7 +78,7 @@ RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) return *this; } -int RGWRESTConn::get_url(string& endpoint) +int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) { if (endpoints.empty()) { ldout(cct, 0) << "ERROR: endpoints not configured for upstream zone" << dendl; @@ -88,15 +88,17 @@ int RGWRESTConn::get_url(string& endpoint) size_t num = 0; while (num < endpoints.size()) { int i = ++counter; - endpoint = endpoints[i % endpoints.size()]; - if (endpoints_status.find(endpoint) == endpoints_status.end()) { - ldout(cct, 1) << "ERROR: missing status for endpoint " << endpoint << dendl; + const string& ep_url = endpoints[i % endpoints.size()]; + endpoint.set_url(ep_url); + + if (endpoints_status.find(ep_url) == endpoints_status.end()) { + ldout(cct, 1) << "ERROR: missing status for endpoint " << ep_url << dendl; num++; continue; } - const auto& upd_time = endpoints_status[endpoint].load(); + const auto& upd_time = endpoints_status[ep_url].load(); if (ceph::real_clock::is_zero(upd_time)) { break; @@ -104,15 +106,15 @@ int RGWRESTConn::get_url(string& endpoint) auto diff = ceph::to_seconds(ceph::real_clock::now() - upd_time); - ldout(cct, 20) << "endpoint url=" << endpoint + ldout(cct, 20) << "endpoint url=" << ep_url << " last endpoint status update time=" << ceph::real_clock::to_double(upd_time) << " diff=" << diff << dendl; static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; if (diff >= CONN_STATUS_EXPIRE_SECS) { - endpoints_status[endpoint].store(ceph::real_clock::zero()); - ldout(cct, 10) << "endpoint " << endpoint << " unconnectable status expired. mark it connectable" << dendl; + endpoints_status[ep_url].store(ceph::real_clock::zero()); + ldout(cct, 10) << "endpoint " << endpoint.get_url() << " unconnectable status expired. mark it connectable" << dendl; break; } num++; @@ -122,29 +124,32 @@ int RGWRESTConn::get_url(string& endpoint) ldout(cct, 5) << "ERROR: no valid endpoint" << dendl; return -EINVAL; } - ldout(cct, 20) << "get_url picked endpoint=" << endpoint << dendl; + ldout(cct, 20) << "get_endpoint picked url=" << endpoint.get_url() + << " connect_to=" << endpoint.get_connect_to() << dendl; return 0; } -string RGWRESTConn::get_url() +RGWEndpoint RGWRESTConn::get_endpoint() { - string endpoint; - get_url(endpoint); + RGWEndpoint endpoint; + get_endpoint(endpoint); return endpoint; } -void RGWRESTConn::set_url_unconnectable(const std::string& endpoint) +void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { - if (endpoint.empty() || endpoints_status.find(endpoint) == endpoints_status.end()) { + const string& url = endpoint.get_url(); + + if (url.empty() || endpoints_status.find(url) == endpoints_status.end()) { ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status. endpoint=" - << endpoint << dendl; + << url << dendl; return; } - endpoints_status[endpoint].store(ceph::real_clock::now()); + endpoints_status[url].store(ceph::real_clock::now()); - ldout(cct, 10) << "set endpoint unconnectable. url=" << endpoint << dendl; + ldout(cct, 10) << "set endpoint unconnectable. url=" << url << dendl; } void RGWRESTConn::populate_params(param_vec_t& params, const rgw_owner* uid, const string& zonegroup) @@ -160,21 +165,22 @@ auto RGWRESTConn::forward(const DoutPrefixProvider *dpp, const rgw_owner& uid, { static constexpr int NUM_ENPOINT_IOERROR_RETRIES = 20; for (int tries = 0; tries < NUM_ENPOINT_IOERROR_RETRIES; tries++) { - string url; - int ret = get_url(url); + RGWEndpoint endpoint; + int ret = get_endpoint(endpoint); if (ret < 0) { return tl::unexpected(ret); } + param_vec_t params; populate_params(params, &uid, self_zone_group); - RGWRESTSimpleRequest req(cct, info.method, url, NULL, ¶ms, api_name); + RGWRESTSimpleRequest req(cct, info.method, endpoint, NULL, ¶ms, api_name); auto result = req.forward_request(dpp, key, info, max_response, inbl, outbl, y); if (result) { return result; } else if (result.error() != -EIO) { return result; } - set_url_unconnectable(url); + set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to forward request. retries=" << tries << dendl; } @@ -189,14 +195,15 @@ auto RGWRESTConn::forward_iam(const DoutPrefixProvider *dpp, const req_info& inf { static constexpr int NUM_ENPOINT_IOERROR_RETRIES = 20; for (int tries = 0; tries < NUM_ENPOINT_IOERROR_RETRIES; tries++) { - string url; - int ret = get_url(url); + RGWEndpoint endpoint; + int ret = get_endpoint(endpoint); if (ret < 0) { return tl::unexpected(ret); } + param_vec_t params; std::string service = "iam"; - RGWRESTSimpleRequest req(cct, info.method, url, NULL, ¶ms, api_name); + RGWRESTSimpleRequest req(cct, info.method, endpoint, NULL, ¶ms, api_name); // coverity[uninit_use_in_call:SUPPRESS] auto result = req.forward_request(dpp, key, info, max_response, inbl, outbl, y, service); if (result) { @@ -204,7 +211,7 @@ auto RGWRESTConn::forward_iam(const DoutPrefixProvider *dpp, const req_info& inf } else if (result.error() != -EIO) { return result; } - set_url_unconnectable(url); + set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to forward request. retries=" << tries << dendl; } @@ -214,8 +221,8 @@ auto RGWRESTConn::forward_iam(const DoutPrefixProvider *dpp, const req_info& inf int RGWRESTConn::put_obj_send_init(const rgw_obj& obj, const rgw_http_param_pair *extra_params, RGWRESTStreamS3PutObj **req) { - string url; - int ret = get_url(url); + RGWEndpoint endpoint; + int ret = get_endpoint(endpoint); if (ret < 0) return ret; @@ -226,7 +233,7 @@ int RGWRESTConn::put_obj_send_init(const rgw_obj& obj, const rgw_http_param_pair append_param_list(params, extra_params); } - RGWRESTStreamS3PutObj *wr = new RGWRESTStreamS3PutObj(cct, "PUT", url, NULL, ¶ms, api_name, host_style); + RGWRESTStreamS3PutObj *wr = new RGWRESTStreamS3PutObj(cct, "PUT", endpoint, NULL, ¶ms, api_name, host_style); // coverity[uninit_use_in_call:SUPPRESS] wr->send_init(obj); *req = wr; @@ -237,14 +244,14 @@ int RGWRESTConn::put_obj_async_init(const DoutPrefixProvider *dpp, const rgw_own map& attrs, RGWRESTStreamS3PutObj **req) { - string url; - int ret = get_url(url); + RGWEndpoint endpoint; + int ret = get_endpoint(endpoint); if (ret < 0) return ret; param_vec_t params; populate_params(params, &uid, self_zone_group); - RGWRESTStreamS3PutObj *wr = new RGWRESTStreamS3PutObj(cct, "PUT", url, NULL, ¶ms, api_name, host_style); + RGWRESTStreamS3PutObj *wr = new RGWRESTStreamS3PutObj(cct, "PUT", endpoint, NULL, ¶ms, api_name, host_style); // coverity[uninit_use_in_call:SUPPRESS] wr->put_obj_init(dpp, key, obj, attrs); *req = wr; @@ -258,7 +265,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, int ret = req->complete_request(dpp, y, &etag, mtime); if (ret == -EIO) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; - set_url_unconnectable(req->get_url_orig()); + set_endpoint_unconnectable(req->get_endpoint_orig()); } delete req; @@ -319,8 +326,8 @@ int RGWRESTConn::get_obj(const DoutPrefixProvider *dpp, const rgw_owner *uid, int RGWRESTConn::get_obj(const DoutPrefixProvider *dpp, const rgw_obj& obj, const get_obj_params& in_params, bool send, RGWRESTStreamRWRequest **req) { - string url; - int ret = get_url(url); + RGWEndpoint endpoint; + int ret = get_endpoint(endpoint); if (ret < 0) return ret; @@ -351,9 +358,9 @@ int RGWRESTConn::get_obj(const DoutPrefixProvider *dpp, const rgw_obj& obj, cons params.push_back(param_pair_t("versionId", obj.key.instance)); } if (in_params.get_op) { - *req = new RGWRESTStreamReadRequest(cct, url, in_params.cb, NULL, ¶ms, api_name, host_style); + *req = new RGWRESTStreamReadRequest(cct, endpoint, in_params.cb, NULL, ¶ms, api_name, host_style); } else { - *req = new RGWRESTStreamHeadRequest(cct, url, in_params.cb, NULL, ¶ms, api_name); + *req = new RGWRESTStreamHeadRequest(cct, endpoint, in_params.cb, NULL, ¶ms, api_name); } map extra_headers; if (in_params.info) { @@ -421,7 +428,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, int ret = req->complete_request(dpp, y, etag, mtime, psize, pattrs, pheaders); if (ret == -EIO) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; - set_url_unconnectable(req->get_url_orig()); + set_endpoint_unconnectable(req->get_endpoint_orig()); } delete req; @@ -441,8 +448,8 @@ int RGWRESTConn::get_resource(const DoutPrefixProvider *dpp, static constexpr int NUM_ENPOINT_IOERROR_RETRIES = 20; for (int tries = 0; tries < NUM_ENPOINT_IOERROR_RETRIES; tries++) { - string url; - ret = get_url(url); + RGWEndpoint endpoint; + ret = get_endpoint(endpoint); if (ret < 0) return ret; @@ -456,7 +463,7 @@ int RGWRESTConn::get_resource(const DoutPrefixProvider *dpp, RGWStreamIntoBufferlist cb(bl); - RGWRESTStreamReadRequest req(cct, url, &cb, NULL, ¶ms, api_name, host_style); + RGWRESTStreamReadRequest req(cct, endpoint, &cb, NULL, ¶ms, api_name, host_style); map headers; if (extra_headers) { @@ -471,7 +478,7 @@ int RGWRESTConn::get_resource(const DoutPrefixProvider *dpp, ret = req.complete_request(dpp, y); if (ret == -EIO) { - set_url_unconnectable(url); + set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to get resource. retries=" << tries << dendl; continue; @@ -495,8 +502,8 @@ int RGWRESTConn::send_resource(const DoutPrefixProvider *dpp, const std::string& static constexpr int NUM_ENPOINT_IOERROR_RETRIES = 20; for (int tries = 0; tries < NUM_ENPOINT_IOERROR_RETRIES; tries++) { - std::string url; - ret = get_url(url); + RGWEndpoint endpoint; + ret = get_endpoint(endpoint); if (ret < 0) return ret; @@ -510,7 +517,7 @@ int RGWRESTConn::send_resource(const DoutPrefixProvider *dpp, const std::string& RGWStreamIntoBufferlist cb(bl); - RGWRESTStreamSendRequest req(cct, method, url, &cb, NULL, ¶ms, api_name, host_style); + RGWRESTStreamSendRequest req(cct, method, endpoint, &cb, NULL, ¶ms, api_name, host_style); std::map headers; if (extra_headers) { @@ -525,7 +532,7 @@ int RGWRESTConn::send_resource(const DoutPrefixProvider *dpp, const std::string& ret = req.complete_request(dpp, y); if (ret == -EIO) { - set_url_unconnectable(url); + set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to send resource. retries=" << tries << dendl; continue; @@ -547,7 +554,7 @@ RGWRESTReadResource::RGWRESTReadResource(RGWRESTConn *_conn, RGWHTTPManager *_mgr) : cct(_conn->get_ctx()), conn(_conn), resource(_resource), params(make_param_list(pp)), cb(bl), mgr(_mgr), - req(cct, conn->get_url(), &cb, NULL, NULL, _conn->get_api_name()) + req(cct, conn->get_endpoint(), &cb, NULL, NULL, _conn->get_api_name()) { init_common(extra_headers); } @@ -558,7 +565,7 @@ RGWRESTReadResource::RGWRESTReadResource(RGWRESTConn *_conn, param_vec_t *extra_headers, RGWHTTPManager *_mgr) : cct(_conn->get_ctx()), conn(_conn), resource(_resource), params(_params), - cb(bl), mgr(_mgr), req(cct, conn->get_url(), &cb, NULL, NULL, _conn->get_api_name()) + cb(bl), mgr(_mgr), req(cct, conn->get_endpoint(), &cb, NULL, NULL, _conn->get_api_name()) { init_common(extra_headers); } @@ -584,7 +591,7 @@ int RGWRESTReadResource::read(const DoutPrefixProvider *dpp, optional_yield y) ret = req.complete_request(dpp, y); if (ret == -EIO) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } @@ -610,7 +617,7 @@ RGWRESTSendResource::RGWRESTSendResource(RGWRESTConn *_conn, RGWHTTPManager *_mgr) : cct(_conn->get_ctx()), conn(_conn), method(_method), resource(_resource), params(make_param_list(pp)), cb(bl), mgr(_mgr), - req(cct, method.c_str(), conn->get_url(), &cb, NULL, NULL, _conn->get_api_name(), _conn->get_host_style()) + req(cct, method.c_str(), conn->get_endpoint(), &cb, NULL, NULL, _conn->get_api_name(), _conn->get_host_style()) { init_common(extra_headers); } @@ -622,7 +629,7 @@ RGWRESTSendResource::RGWRESTSendResource(RGWRESTConn *_conn, param_vec_t *extra_headers, RGWHTTPManager *_mgr) : cct(_conn->get_ctx()), conn(_conn), method(_method), resource(_resource), params(params), - cb(bl), mgr(_mgr), req(cct, method.c_str(), conn->get_url(), &cb, NULL, NULL, _conn->get_api_name(), _conn->get_host_style()) + cb(bl), mgr(_mgr), req(cct, method.c_str(), conn->get_endpoint(), &cb, NULL, NULL, _conn->get_api_name(), _conn->get_host_style()) { init_common(extra_headers); } @@ -651,7 +658,7 @@ int RGWRESTSendResource::send(const DoutPrefixProvider *dpp, bufferlist& outbl, ret = req.complete_request(dpp, y); if (ret == -EIO) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 0672e5bc138..5042add9d50 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -101,9 +101,9 @@ public: RGWRESTConn& operator=(RGWRESTConn&& other); virtual ~RGWRESTConn() = default; - int get_url(std::string& endpoint); - std::string get_url(); - void set_url_unconnectable(const std::string& endpoint); + int get_endpoint(RGWEndpoint& endpoint); + RGWEndpoint get_endpoint(); + void set_endpoint_unconnectable(const RGWEndpoint& endpoint); const std::string& get_self_zonegroup() { return self_zone_group; } @@ -358,7 +358,7 @@ public: int ret = req.wait(dpp, y); if (ret < 0) { if (ret == -ERR_INTERNAL_ERROR) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); } return ret; } @@ -414,7 +414,7 @@ int RGWRESTReadResource::wait(const DoutPrefixProvider* dpp, T *dest, int ret = req.wait(dpp, y); if (ret < 0) { if (ret == -ERR_INTERNAL_ERROR) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); } return ret; } @@ -489,7 +489,7 @@ public: *pbl = bl; if (ret == -ERR_INTERNAL_ERROR) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); } if (ret < 0 && err_result ) { @@ -510,7 +510,7 @@ int RGWRESTSendResource::wait(const DoutPrefixProvider* dpp, T *dest, { int ret = req.wait(dpp, y); if (ret == -ERR_INTERNAL_ERROR) { - conn->set_url_unconnectable(req.get_url_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint_orig()); } if (ret >= 0) { diff --git a/src/rgw/services/svc_zone.cc b/src/rgw/services/svc_zone.cc index 1bce12e934b..055a5f6fe69 100644 --- a/src/rgw/services/svc_zone.cc +++ b/src/rgw/services/svc_zone.cc @@ -671,7 +671,7 @@ int RGWSI_Zone::select_bucket_placement(const DoutPrefixProvider *dpp, const RGW pselected_rule, rule_info, y); } -bool RGWSI_Zone::get_redirect_zone_endpoint(string *endpoint) +bool RGWSI_Zone::get_redirect_zone_endpoint_url(string *url) { if (zone_public_config->redirect_zone.empty()) { return false; @@ -685,11 +685,13 @@ bool RGWSI_Zone::get_redirect_zone_endpoint(string *endpoint) RGWRESTConn *conn = iter->second; - int ret = conn->get_url(*endpoint); + RGWEndpoint ep{*url}; + int ret = conn->get_endpoint(ep); if (ret < 0) { ldout(cct, 0) << "ERROR: redirect zone, conn->get_endpoint() returned ret=" << ret << dendl; return false; } + *url = ep.get_url(); return true; } diff --git a/src/rgw/services/svc_zone.h b/src/rgw/services/svc_zone.h index 568c6d5603e..451faac8bef 100644 --- a/src/rgw/services/svc_zone.h +++ b/src/rgw/services/svc_zone.h @@ -92,7 +92,7 @@ public: bool zone_is_writeable(); bool zone_syncs_from(const RGWZone& target_zone, const RGWZone& source_zone) const; bool zone_syncs_from(const RGWZone& source_zone) const; - bool get_redirect_zone_endpoint(std::string *endpoint); + bool get_redirect_zone_endpoint_url(std::string *url); bool sync_module_supports_writes() const { return writeable_zone; } bool sync_module_exports_data() const { return exports_data; } From d989ef7b688d371d60c99afaaf5ef2e4288d019a Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Fri, 19 Dec 2025 02:25:55 +0000 Subject: [PATCH 077/596] rgw: add rgw_resolve_endpoints_into_all_addresses config option Introduce an advanced boolean config option (default: false) to enable resolving multisite endpoint hostnames into all A/AAAA records. When enabled, RGW can distribute outgoing inter-zone traffic across DNS-provided backends even without an external load balancer. Signed-off-by: Oguzhan Ozmen --- src/common/options/rgw.yaml.in | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index 6e50726457d..2d1c4cb777f 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -2065,6 +2065,22 @@ options: services: - rgw with_legacy: true +- name: rgw_rest_conn_connect_to_resolved_ips + type: bool + level: advanced + long_desc: When an RGW endpoint hostname resolves to multiple A or AAAA + records, libcurl normally connects to only the first address returned by + DNS. Enabling this option causes RGW to resolve each configured endpoint + into all of its addresses and distribute outgoing requests across them + using round-robin, with per-IP health tracking. This applies to + multisite replication traffic between zones (via RGWRESTConn). For example, in a + multisite deployment where zone endpoints such as "https://zone-a.example.com" + map to several backend RGW nodes, this allows inter-zone traffic to be spread + across all peers without requiring an external load balancer. + default: false + services: + - rgw + with_legacy: true - name: rgw_obj_stripe_size type: size level: advanced From af7abeea009c32884a268f8513d1d1f9ae41726d Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Fri, 23 Jan 2026 15:34:18 +0000 Subject: [PATCH 078/596] rgw/rest: resolve multisite endpoints to all A/AAAA records (optional) When rgw_resolve_endpoints_into_all_addresses=true, parse each configured endpoint URL, extract host/port, and resolve the host into all IP addresses. Store resolution results (including a round-robin index) alongside the original endpoint URL for later selection. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 77 ++++++++++++++++++++++++++++++++++++++++ src/rgw/rgw_rest_conn.h | 13 +++++++ 2 files changed, 90 insertions(+) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 0f32cc201f1..95853bacc28 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -5,11 +5,80 @@ #include "rgw_rest_conn.h" #include "rgw_http_errors.h" #include "rgw_sal.h" +#include +#include + +#include #define dout_subsys ceph_subsys_rgw using namespace std; +void RGWRESTConn::resolve_endpoints() { + resolved_endpoints.reserve(endpoints.size()); + + for (const auto& ep_url : endpoints) { + ResolvedEndpoint res_ep; + res_ep.url = ep_url; + + // parse URL + boost::system::result r = boost::urls::parse_uri(ep_url); + if (r.has_error()) { + ldout(cct, 0) << "RGWRESTConn: invalid endpoint url=" << ep_url + << " err=" << r.error().message() << dendl; + continue; + } + boost::urls::url_view u = r.value(); + + // scheme + std::string scheme = std::string(u.scheme()); + if (scheme.empty()) { + scheme = "http"; + } + res_ep.scheme = scheme; + + // host + res_ep.host = std::string(u.host()); + if (res_ep.host.empty()) { + ldout(cct, 0) << "RGWRESTConn: endpoint url=" << ep_url + << " has empty host" << dendl; + continue; + } + + // port + if (u.has_port()) { + try { + res_ep.port = static_cast(std::stoi(std::string(u.port()))); + } catch (...) { + ldout(cct, 0) << "RGWRESTConn: invalid port in endpoint url=" << ep_url<< dendl; + continue; + } + } else { + res_ep.port = (scheme == "https" ? 443 : 80); + } + + // resolve all IP addresses for the host + boost::asio::io_context io_ctx; + boost::asio::ip::tcp::resolver resolver(io_ctx); + boost::system::error_code ec; + auto results = resolver.resolve(res_ep.host, "", ec); + if (!ec && !results.empty()) { + for (const auto& entry : results) { + auto ip_str = entry.endpoint().address().to_string(); + res_ep.ips.push_back(ip_str); + ldout(cct, 1) << "endpoint_url=" << ep_url << " resolved to ip=" << ip_str << dendl; + } + ldout(cct, 1) << "endpoint=" << ep_url << " resolved to " + << res_ep.ips.size() << " IP addresses" << dendl; + } else { + ldout(cct, 0) << "WARNING: RGWRESTConn no IP addresses found for endpoint=" << ep_url + << (ec ? " err=" + ec.message() : "") << dendl; + } + + resolved_endpoints.push_back(std::move(res_ep)); + } +} + RGWRESTConn::RGWRESTConn(CephContext *_cct, rgw::sal::Driver* driver, const string& _remote_id, const list& remote_endpoints, @@ -26,6 +95,9 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, rgw::sal::Driver* driver, [this](const auto& url) { this->endpoints_status.emplace(url, ceph::real_clock::zero()); }); + if (cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { + resolve_endpoints(); + } if (driver) { key = driver->get_zone()->get_system_key(); @@ -53,11 +125,15 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, [this](const auto& url) { this->endpoints_status.emplace(url, ceph::real_clock::zero()); }); + if (cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { + resolve_endpoints(); + } } RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) : cct(other.cct), endpoints(std::move(other.endpoints)), + resolved_endpoints(std::move(other.resolved_endpoints)), endpoints_status(std::move(other.endpoints_status)), key(std::move(other.key)), self_zone_group(std::move(other.self_zone_group)), @@ -70,6 +146,7 @@ RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) { cct = other.cct; endpoints = std::move(other.endpoints); + resolved_endpoints = std::move(other.resolved_endpoints); endpoints_status = std::move(other.endpoints_status); key = std::move(other.key); self_zone_group = std::move(other.self_zone_group); diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 5042add9d50..0dc0914a3be 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -65,6 +65,15 @@ inline param_vec_t make_param_list(const std::map *pp) return params; } +struct ResolvedEndpoint { + std::string url; // e.g., "https://s3.abc.com:8443" + std::string scheme; // e.g., "https" + std::string host; // e.g., "s3.abc.com" + int port = -1; // e.g., 443 + std::vector ips; // the IPs the endpoint resolves to + size_t rr_index = 0; // round-robin index for IPs +}; + class RGWRESTConn { /* the endpoint is not able to connect if the timestamp is not real_clock::zero */ @@ -72,6 +81,7 @@ class RGWRESTConn CephContext *cct; std::vector endpoints; + std::vector resolved_endpoints; endpoint_status_map endpoints_status; RGWAccessKey key; std::string self_zone_group; @@ -80,6 +90,8 @@ class RGWRESTConn HostStyle host_style; std::atomic counter = { 0 }; + void resolve_endpoints(void); + public: RGWRESTConn(CephContext *_cct, @@ -103,6 +115,7 @@ public: int get_endpoint(RGWEndpoint& endpoint); RGWEndpoint get_endpoint(); + const std::vector& get_resolved_endpoints() const { return resolved_endpoints; } void set_endpoint_unconnectable(const RGWEndpoint& endpoint); const std::string& get_self_zonegroup() { return self_zone_group; From 510e9ab1f0a728ebab48376acb06315e477367eb Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Fri, 23 Jan 2026 18:51:22 +0000 Subject: [PATCH 079/596] rgw/rest: round-robin resolved endpoint IPs into curl CONNECT_TO mapping Add logic to select an IP for a given endpoint URL (RR over resolved addresses) and build a host:port:ip:port mapping. Store the mapping in the RGWEndpoint so the HTTP layer can apply it per request. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 32 ++++++++++++++++++++++++++++---- src/rgw/rgw_rest_conn.h | 8 ++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 95853bacc28..71996fcb401 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -63,19 +63,23 @@ void RGWRESTConn::resolve_endpoints() { boost::system::error_code ec; auto results = resolver.resolve(res_ep.host, "", ec); if (!ec && !results.empty()) { + std::string port_str = std::to_string(res_ep.port); + std::string host_port_prefix = res_ep.host + ":" + port_str + ":"; + for (const auto& entry : results) { auto ip_str = entry.endpoint().address().to_string(); res_ep.ips.push_back(ip_str); - ldout(cct, 1) << "endpoint_url=" << ep_url << " resolved to ip=" << ip_str << dendl; + res_ep.connect_to_strings.emplace_back(host_port_prefix + ip_str + ":" + port_str); + ldout(cct, 2) << "endpoint_url=" << ep_url << " resolved to ip=" << ip_str << dendl; } - ldout(cct, 1) << "endpoint=" << ep_url << " resolved to " + ldout(cct, 2) << "endpoint=" << ep_url << " resolved to " << res_ep.ips.size() << " IP addresses" << dendl; } else { ldout(cct, 0) << "WARNING: RGWRESTConn no IP addresses found for endpoint=" << ep_url << (ec ? " err=" + ec.message() : "") << dendl; } - resolved_endpoints.push_back(std::move(res_ep)); + resolved_endpoints.emplace(ep_url, std::move(res_ep)); } } @@ -155,6 +159,24 @@ RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) return *this; } +void RGWRESTConn::get_connect_to_mapping_for_url(RGWEndpoint& endpoint) +{ + if (!cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { + return; + } + + std::string connect_to; + + auto it = resolved_endpoints.find(endpoint.get_url()); + if (it != resolved_endpoints.end() && !it->second.connect_to_strings.empty()) { + auto& res_ep = it->second; + size_t idx = res_ep.rr_index++; + connect_to = res_ep.connect_to_strings[idx % res_ep.connect_to_strings.size()]; + } + + endpoint.set_connect_to(connect_to); +} + int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) { if (endpoints.empty()) { @@ -201,7 +223,9 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) ldout(cct, 5) << "ERROR: no valid endpoint" << dendl; return -EINVAL; } - ldout(cct, 20) << "get_endpoint picked url=" << endpoint.get_url() + + get_connect_to_mapping_for_url(endpoint); + ldout(cct, 20) << "get_endpoint picked endpoint url=" << endpoint.get_url() << " connect_to=" << endpoint.get_connect_to() << dendl; return 0; diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 0dc0914a3be..f29151c97c7 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -71,6 +71,7 @@ struct ResolvedEndpoint { std::string host; // e.g., "s3.abc.com" int port = -1; // e.g., 443 std::vector ips; // the IPs the endpoint resolves to + std::vector connect_to_strings; // Pre-computed full connect_to strings for each IP size_t rr_index = 0; // round-robin index for IPs }; @@ -81,7 +82,7 @@ class RGWRESTConn CephContext *cct; std::vector endpoints; - std::vector resolved_endpoints; + std::unordered_map resolved_endpoints; endpoint_status_map endpoints_status; RGWAccessKey key; std::string self_zone_group; @@ -115,7 +116,7 @@ public: int get_endpoint(RGWEndpoint& endpoint); RGWEndpoint get_endpoint(); - const std::vector& get_resolved_endpoints() const { return resolved_endpoints; } + const std::unordered_map& get_resolved_endpoints() const { return resolved_endpoints; } void set_endpoint_unconnectable(const RGWEndpoint& endpoint); const std::string& get_self_zonegroup() { return self_zone_group; @@ -162,6 +163,9 @@ public: RGWRESTStreamS3PutObj *req, std::string& etag, ceph::real_time *mtime, optional_yield y); + /* pick an IP to 'connect-to' given the endpoint url */ + void get_connect_to_mapping_for_url(RGWEndpoint& endpoint); + struct get_obj_params { const rgw_owner *uid{nullptr}; const rgw_user *perm_check_uid{nullptr}; From 6aca73a5f5be7acf2262b9cda3c7591e7ca6526b Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Fri, 23 Jan 2026 19:07:19 +0000 Subject: [PATCH 080/596] rgw/http: apply RGWEndpoint connect_to via libcurl CURLOPT_CONNECT_TO If endpoint.connect_to is non-empty, populate and attach a curl slist to CURLOPT_CONNECT_TO for this request. Ensure the slist is freed with the request lifetime to avoid leaks across retries/requests. Fixes: https://tracker.ceph.com/issues/74677 Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_http_client.cc | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/rgw/rgw_http_client.cc b/src/rgw/rgw_http_client.cc index 4cf35d09f26..788b18f6fa1 100644 --- a/src/rgw/rgw_http_client.cc +++ b/src/rgw/rgw_http_client.cc @@ -35,6 +35,7 @@ static void do_curl_easy_cleanup(RGWCurlHandle *curl_handle); struct rgw_http_req_data : public RefCountedObject { RGWCurlHandle *curl_handle{nullptr}; curl_slist *h{nullptr}; + curl_slist *connect_to_slist{nullptr}; uint64_t id; int ret{0}; std::atomic done = { false }; @@ -106,7 +107,14 @@ struct rgw_http_req_data : public RefCountedObject { curl_handle = NULL; h = NULL; + + if (connect_to_slist) { + curl_slist_free_all(connect_to_slist); + connect_to_slist = nullptr; + } + done = true; + if (completion) { boost::system::error_code ec(-ret, boost::system::system_category()); Completion::post(std::move(completion), ec); @@ -588,6 +596,23 @@ int RGWHTTPClient::init_request(rgw_http_req_data *_req_data) curl_easy_setopt(easy_handle, CURLOPT_CUSTOMREQUEST, method.c_str()); curl_easy_setopt(easy_handle, CURLOPT_URL, endpoint.get_url().c_str()); + + // apply CONNECT_TO mapping if provided for this request + if (! endpoint.get_connect_to().empty()) { + if (req_data->connect_to_slist) { + curl_slist_free_all(req_data->connect_to_slist); + req_data->connect_to_slist = nullptr; + } + + req_data->connect_to_slist = curl_slist_append(req_data->connect_to_slist, endpoint.get_connect_to().c_str()); + if (! req_data->connect_to_slist) { + dout(0) << "ERROR: RGWHTTPClient::init_request failed to allocate connect_to_slist" << dendl; + } else { + dout(20) << "applying CURLOPT_CONNECT_TO=" << endpoint.get_connect_to() << " for url=" << endpoint.get_url() << dendl; + curl_easy_setopt(easy_handle, CURLOPT_CONNECT_TO, req_data->connect_to_slist); + } + } + curl_easy_setopt(easy_handle, CURLOPT_NOPROGRESS, 1L); curl_easy_setopt(easy_handle, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(easy_handle, CURLOPT_HEADERFUNCTION, receive_http_header); From 030e62b8f14c232d1ba08928e5c16a337d51dba0 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Sat, 7 Feb 2026 01:45:20 +0000 Subject: [PATCH 081/596] rgw/rest: consolidate endpoint status tracking into ResolvedEndpoint Refactor RGWRESTConn to eliminate the separate endpoints_status map by moving the connection status (std::atomic) directly into the ResolvedEndpoint struct. This reduces redundancy and simplifies endpoint state management. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 69 ++++++++++++++++------------------------ src/rgw/rgw_rest_conn.h | 46 ++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 49 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 71996fcb401..fb54bc57a81 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -15,12 +15,7 @@ using namespace std; void RGWRESTConn::resolve_endpoints() { - resolved_endpoints.reserve(endpoints.size()); - - for (const auto& ep_url : endpoints) { - ResolvedEndpoint res_ep; - res_ep.url = ep_url; - + for (auto& [ep_url, res_ep] : resolved_endpoints) { // parse URL boost::system::result r = boost::urls::parse_uri(ep_url); if (r.has_error()) { @@ -78,8 +73,6 @@ void RGWRESTConn::resolve_endpoints() { ldout(cct, 0) << "WARNING: RGWRESTConn no IP addresses found for endpoint=" << ep_url << (ec ? " err=" + ec.message() : "") << dendl; } - - resolved_endpoints.emplace(ep_url, std::move(res_ep)); } } @@ -89,19 +82,17 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, rgw::sal::Driver* driver, std::optional _api_name, HostStyle _host_style) : cct(_cct), - endpoints(remote_endpoints.begin(), remote_endpoints.end()), + endpoint_urls(remote_endpoints.begin(), remote_endpoints.end()), remote_id(_remote_id), api_name(_api_name), host_style(_host_style) { - endpoints_status.reserve(remote_endpoints.size()); - std::for_each(remote_endpoints.begin(), remote_endpoints.end(), - [this](const auto& url) { - this->endpoints_status.emplace(url, ceph::real_clock::zero()); - }); - if (cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { - resolve_endpoints(); + resolved_endpoints.reserve(remote_endpoints.size()); + for (const auto& ep_url : remote_endpoints) { + ResolvedEndpoint& res_ep = resolved_endpoints[ep_url]; + res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable } + resolve_endpoints(); if (driver) { key = driver->get_zone()->get_system_key(); @@ -117,45 +108,41 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, std::optional _api_name, HostStyle _host_style) : cct(_cct), - endpoints(remote_endpoints.begin(), remote_endpoints.end()), + endpoint_urls(remote_endpoints.begin(), remote_endpoints.end()), key(_cred), self_zone_group(_zone_group), remote_id(_remote_id), api_name(_api_name), host_style(_host_style) { - endpoints_status.reserve(remote_endpoints.size()); - std::for_each(remote_endpoints.begin(), remote_endpoints.end(), - [this](const auto& url) { - this->endpoints_status.emplace(url, ceph::real_clock::zero()); - }); - if (cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { - resolve_endpoints(); + resolved_endpoints.reserve(remote_endpoints.size()); + for (const auto& ep_url : remote_endpoints) { + ResolvedEndpoint& res_ep = resolved_endpoints[ep_url]; + res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable } + resolve_endpoints(); } RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) : cct(other.cct), - endpoints(std::move(other.endpoints)), + endpoint_urls(std::move(other.endpoint_urls)), + endpoint_urls_counter(other.endpoint_urls_counter.load()), resolved_endpoints(std::move(other.resolved_endpoints)), - endpoints_status(std::move(other.endpoints_status)), key(std::move(other.key)), self_zone_group(std::move(other.self_zone_group)), - remote_id(std::move(other.remote_id)), - counter(other.counter.load()) + remote_id(std::move(other.remote_id)) { } RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) { cct = other.cct; - endpoints = std::move(other.endpoints); + endpoint_urls = std::move(other.endpoint_urls); + endpoint_urls_counter = other.endpoint_urls_counter.load(); resolved_endpoints = std::move(other.resolved_endpoints); - endpoints_status = std::move(other.endpoints_status); key = std::move(other.key); self_zone_group = std::move(other.self_zone_group); remote_id = std::move(other.remote_id); - counter = other.counter.load(); return *this; } @@ -179,25 +166,25 @@ void RGWRESTConn::get_connect_to_mapping_for_url(RGWEndpoint& endpoint) int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) { - if (endpoints.empty()) { + if (endpoint_urls.empty()) { ldout(cct, 0) << "ERROR: endpoints not configured for upstream zone" << dendl; return -EINVAL; } size_t num = 0; - while (num < endpoints.size()) { - int i = ++counter; + while (num < endpoint_urls.size()) { + int i = ++endpoint_urls_counter; - const string& ep_url = endpoints[i % endpoints.size()]; + const string& ep_url = endpoint_urls[i % endpoint_urls.size()]; endpoint.set_url(ep_url); - if (endpoints_status.find(ep_url) == endpoints_status.end()) { + if (resolved_endpoints.find(ep_url) == resolved_endpoints.end()) { ldout(cct, 1) << "ERROR: missing status for endpoint " << ep_url << dendl; num++; continue; } - const auto& upd_time = endpoints_status[ep_url].load(); + const auto& upd_time = resolved_endpoints[ep_url].status.load(); if (ceph::real_clock::is_zero(upd_time)) { break; @@ -212,14 +199,14 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; if (diff >= CONN_STATUS_EXPIRE_SECS) { - endpoints_status[ep_url].store(ceph::real_clock::zero()); + resolved_endpoints[ep_url].status.store(ceph::real_clock::zero()); ldout(cct, 10) << "endpoint " << endpoint.get_url() << " unconnectable status expired. mark it connectable" << dendl; break; } num++; }; - if (num == endpoints.size()) { + if (num == endpoint_urls.size()) { ldout(cct, 5) << "ERROR: no valid endpoint" << dendl; return -EINVAL; } @@ -242,13 +229,13 @@ void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { const string& url = endpoint.get_url(); - if (url.empty() || endpoints_status.find(url) == endpoints_status.end()) { + if (url.empty() || resolved_endpoints.find(url) == resolved_endpoints.end()) { ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status. endpoint=" << url << dendl; return; } - endpoints_status[url].store(ceph::real_clock::now()); + resolved_endpoints[url].status.store(ceph::real_clock::now()); ldout(cct, 10) << "set endpoint unconnectable. url=" << url << dendl; } diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index f29151c97c7..700d06f5407 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -69,27 +69,57 @@ struct ResolvedEndpoint { std::string url; // e.g., "https://s3.abc.com:8443" std::string scheme; // e.g., "https" std::string host; // e.g., "s3.abc.com" - int port = -1; // e.g., 443 + int port = -1; // e.g., 8443 std::vector ips; // the IPs the endpoint resolves to std::vector connect_to_strings; // Pre-computed full connect_to strings for each IP size_t rr_index = 0; // round-robin index for IPs + + /* endpoint health state: the endpoint is not able to connect if the timestamp is not real_clock::zero */ + std::atomic status; + + ResolvedEndpoint() = default; + + // Custom move constructor (required because std::atomic is not movable) + ResolvedEndpoint(ResolvedEndpoint&& other) noexcept + : url(std::move(other.url)), + scheme(std::move(other.scheme)), + host(std::move(other.host)), + port(other.port), + ips(std::move(other.ips)), + connect_to_strings(std::move(other.connect_to_strings)), + rr_index(other.rr_index), + status(other.status.load()) + {} + + // Custom move assignment (required because std::atomic is not movable) + ResolvedEndpoint& operator=(ResolvedEndpoint&& other) noexcept { + url = std::move(other.url); + scheme = std::move(other.scheme); + host = std::move(other.host); + port = other.port; + ips = std::move(other.ips); + connect_to_strings = std::move(other.connect_to_strings); + rr_index = other.rr_index; + status.store(other.status.load()); + return *this; + } + + // Delete copy operations (std::atomic is not copyable) + ResolvedEndpoint(const ResolvedEndpoint&) = delete; + ResolvedEndpoint& operator=(const ResolvedEndpoint&) = delete; }; class RGWRESTConn { - /* the endpoint is not able to connect if the timestamp is not real_clock::zero */ - using endpoint_status_map = std::unordered_map>; - CephContext *cct; - std::vector endpoints; + std::vector endpoint_urls; // For ordered round-robin + std::atomic endpoint_urls_counter = { 0 }; // Round-robin counter for endpoint_urls std::unordered_map resolved_endpoints; - endpoint_status_map endpoints_status; RGWAccessKey key; std::string self_zone_group; std::string remote_id; std::optional api_name; HostStyle host_style; - std::atomic counter = { 0 }; void resolve_endpoints(void); @@ -139,7 +169,7 @@ public: CephContext *get_ctx() { return cct; } - size_t get_endpoint_count() const { return endpoints.size(); } + size_t get_endpoint_count() const { return endpoint_urls.size(); } virtual void populate_params(param_vec_t& params, const rgw_owner* uid, const std::string& zonegroup); From e468f0f65ffde4b3c9db47a42729124504327684 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Sat, 7 Feb 2026 02:00:16 +0000 Subject: [PATCH 082/596] rgw: fix incomplete RGWRESTConn move constructor/assignment Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index fb54bc57a81..727cef4331c 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -130,7 +130,9 @@ RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) resolved_endpoints(std::move(other.resolved_endpoints)), key(std::move(other.key)), self_zone_group(std::move(other.self_zone_group)), - remote_id(std::move(other.remote_id)) + remote_id(std::move(other.remote_id)), + api_name(std::move(other.api_name)), + host_style(other.host_style) { } @@ -143,6 +145,8 @@ RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) key = std::move(other.key); self_zone_group = std::move(other.self_zone_group); remote_id = std::move(other.remote_id); + api_name = std::move(other.api_name); + host_style = other.host_style; return *this; } From b8d8c40edcc176b8f07bb143cbe2269e05f4bed8 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Sat, 7 Feb 2026 17:33:42 +0000 Subject: [PATCH 083/596] rgw: track original URL within RGWEndpoint instead of separate member (refactor) Refactor endpoint tracking by adding original_url to RGWEndpoint struct instead of maintaining a separate endpoint_orig member in RGWHTTPClient. This simplifies the code by having each endpoint self-track its original URL, which is needed for connection status lookups after URL modifications. No functional changes intended. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_http_client.cc | 1 - src/rgw/rgw_http_client.h | 35 ++++++++++++++++++++++++++++------- src/rgw/rgw_rest_client.h | 2 +- src/rgw/rgw_rest_conn.cc | 20 ++++++++++---------- src/rgw/rgw_rest_conn.h | 8 ++++---- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/rgw/rgw_http_client.cc b/src/rgw/rgw_http_client.cc index 788b18f6fa1..94ed211c8c4 100644 --- a/src/rgw/rgw_http_client.cc +++ b/src/rgw/rgw_http_client.cc @@ -310,7 +310,6 @@ RGWHTTPClient::RGWHTTPClient(CephContext *cct, verify_ssl(cct->_conf->rgw_verify_ssl), cct(cct), method(_method), - endpoint_orig(_endpoint), endpoint(_endpoint) { init(); } diff --git a/src/rgw/rgw_http_client.h b/src/rgw/rgw_http_client.h index 6eaf1844401..942824aa9d6 100644 --- a/src/rgw/rgw_http_client.h +++ b/src/rgw/rgw_http_client.h @@ -20,9 +20,24 @@ void rgw_http_client_cleanup(); struct rgw_http_req_data; class RGWHTTPManager; +/** + * RGWEndpoint - Represents an HTTP endpoint with additional metdata such as connection routing. + * + * Unlike a plain URL string, RGWEndpoint carries additional information needed + * to leverage libcurl's CURLOPT_CONNECT_TO option. This enables RGW to route + * requests to specific IP addresses while preserving the original hostname for + * TLS/SNI and Host headers. + * + * Fields: + * - url: The effective URL for the request (may be modified with paths). + * - original_url: The initially configured endpoint URL (preserved for reference). + * - connect_to: libcurl CONNECT_TO string (format: "host:port:addr:port") to + * override connection routing without changing the request URL. + */ struct RGWEndpoint { private: std::string url; + std::string original_url; std::string connect_to; public: @@ -31,7 +46,7 @@ public: RGWEndpoint(const char* u) : RGWEndpoint(std::string(u)) {} RGWEndpoint(std::string u, std::string c = {}) - : url(std::move(u)), connect_to(std::move(c)) {} + : url(std::move(u)), original_url(url), connect_to(std::move(c)) {} RGWEndpoint with_url(std::string new_url) const { RGWEndpoint e = *this; @@ -39,8 +54,15 @@ public: return e; } - void set_url(const std::string& _url) { url = _url; } + void set_url(const std::string& _url) { + url = _url; + // Capture the first URL assignment as the original + if (original_url.empty()) { + original_url = _url; + } + } const std::string& get_url() const { return url; } + const std::string& get_original_url() const { return original_url; } void set_connect_to(const std::string& _connect_to) { connect_to = _connect_to; } const std::string& get_connect_to() const { return connect_to; } @@ -86,7 +108,6 @@ protected: CephContext *cct; std::string method; - RGWEndpoint endpoint_orig; RGWEndpoint endpoint; std::string protocol; @@ -204,6 +225,10 @@ public: int get_req_retcode(); + const RGWEndpoint& get_endpoint() const { + return endpoint; + } + void set_endpoint(const RGWEndpoint& _endpoint) { endpoint = _endpoint; } @@ -212,10 +237,6 @@ public: endpoint.set_url(_url); } - const RGWEndpoint& get_endpoint_orig() const { - return endpoint_orig; - } - void set_method(const std::string& _method) { method = _method; } diff --git a/src/rgw/rgw_rest_client.h b/src/rgw/rgw_rest_client.h index e9458f44f7e..47d60b8ba7f 100644 --- a/src/rgw/rgw_rest_client.h +++ b/src/rgw/rgw_rest_client.h @@ -101,7 +101,7 @@ public: void set_policy(const RGWAccessControlPolicy& policy); int sign(const DoutPrefixProvider *dpp, RGWAccessKey& key, const bufferlist *opt_content); - const RGWEndpoint& get_endpoint() { return endpoint; } + const RGWEndpoint& get_endpoint() const { return endpoint; } }; class RGWHTTPStreamRWRequest : public RGWHTTPSimpleRequest { diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 727cef4331c..6c86d4e29fd 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -231,17 +231,17 @@ RGWEndpoint RGWRESTConn::get_endpoint() void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { - const string& url = endpoint.get_url(); + const string& orig_url = endpoint.get_original_url(); - if (url.empty() || resolved_endpoints.find(url) == resolved_endpoints.end()) { - ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status. endpoint=" - << url << dendl; + if (orig_url.empty() || resolved_endpoints.find(orig_url) == resolved_endpoints.end()) { + ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status. " + << " original_url=" << orig_url << " current_url=" << endpoint.get_url() << dendl; return; } - resolved_endpoints[url].status.store(ceph::real_clock::now()); + resolved_endpoints[orig_url].status.store(ceph::real_clock::now()); - ldout(cct, 10) << "set endpoint unconnectable. url=" << url << dendl; + ldout(cct, 10) << "set endpoint unconnectable. url=" << orig_url << dendl; } void RGWRESTConn::populate_params(param_vec_t& params, const rgw_owner* uid, const string& zonegroup) @@ -357,7 +357,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, int ret = req->complete_request(dpp, y, &etag, mtime); if (ret == -EIO) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; - set_endpoint_unconnectable(req->get_endpoint_orig()); + set_endpoint_unconnectable(req->get_endpoint()); } delete req; @@ -520,7 +520,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, int ret = req->complete_request(dpp, y, etag, mtime, psize, pattrs, pheaders); if (ret == -EIO) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; - set_endpoint_unconnectable(req->get_endpoint_orig()); + set_endpoint_unconnectable(req->get_endpoint()); } delete req; @@ -683,7 +683,7 @@ int RGWRESTReadResource::read(const DoutPrefixProvider *dpp, optional_yield y) ret = req.complete_request(dpp, y); if (ret == -EIO) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } @@ -750,7 +750,7 @@ int RGWRESTSendResource::send(const DoutPrefixProvider *dpp, bufferlist& outbl, ret = req.complete_request(dpp, y); if (ret == -EIO) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 700d06f5407..29613d62820 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -405,7 +405,7 @@ public: int ret = req.wait(dpp, y); if (ret < 0) { if (ret == -ERR_INTERNAL_ERROR) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); } return ret; } @@ -461,7 +461,7 @@ int RGWRESTReadResource::wait(const DoutPrefixProvider* dpp, T *dest, int ret = req.wait(dpp, y); if (ret < 0) { if (ret == -ERR_INTERNAL_ERROR) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); } return ret; } @@ -536,7 +536,7 @@ public: *pbl = bl; if (ret == -ERR_INTERNAL_ERROR) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); } if (ret < 0 && err_result ) { @@ -557,7 +557,7 @@ int RGWRESTSendResource::wait(const DoutPrefixProvider* dpp, T *dest, { int ret = req.wait(dpp, y); if (ret == -ERR_INTERNAL_ERROR) { - conn->set_endpoint_unconnectable(req.get_endpoint_orig()); + conn->set_endpoint_unconnectable(req.get_endpoint()); } if (ret >= 0) { From 71af1fc945e78c8f7fa460e3835733ad93a9754a Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Sun, 8 Feb 2026 00:30:13 +0000 Subject: [PATCH 084/596] rgw: add operator<< for RGWEndpoint and simplify logging Add ostream operator<< to RGWEndpoint struct for convenient logging of endpoint details (url, original_url when different, and connect_to). Update log statements across rgw_http_client.cc, rgw_rest_client.cc, and rgw_rest_conn.cc to use the new operator for cleaner, more consistent output. Add unittest_rgw_http_client to test RGWEndpoint functionality. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_http_client.cc | 7 +- src/rgw/rgw_http_client.h | 11 +++ src/rgw/rgw_rest_client.cc | 7 +- src/rgw/rgw_rest_conn.cc | 9 +- src/test/rgw/CMakeLists.txt | 6 ++ src/test/rgw/test_rgw_http_client.cc | 132 +++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/test/rgw/test_rgw_http_client.cc diff --git a/src/rgw/rgw_http_client.cc b/src/rgw/rgw_http_client.cc index 94ed211c8c4..1b1d5bae8b7 100644 --- a/src/rgw/rgw_http_client.cc +++ b/src/rgw/rgw_http_client.cc @@ -586,8 +586,7 @@ int RGWHTTPClient::init_request(rgw_http_req_data *_req_data) CURL *easy_handle = req_data->get_easy_handle(); - dout(20) << "sending request to url=" << endpoint.get_url() - << " connect_to=" << endpoint.get_connect_to() << dendl; + dout(20) << "sending request to " << endpoint << dendl; curl_slist *h = headers_to_slist(headers); @@ -605,9 +604,9 @@ int RGWHTTPClient::init_request(rgw_http_req_data *_req_data) req_data->connect_to_slist = curl_slist_append(req_data->connect_to_slist, endpoint.get_connect_to().c_str()); if (! req_data->connect_to_slist) { - dout(0) << "ERROR: RGWHTTPClient::init_request failed to allocate connect_to_slist" << dendl; + dout(0) << "ERROR: RGWHTTPClient::init_request failed to allocate connect_to_slist: " << endpoint << dendl; } else { - dout(20) << "applying CURLOPT_CONNECT_TO=" << endpoint.get_connect_to() << " for url=" << endpoint.get_url() << dendl; + dout(20) << "applying CURLOPT_CONNECT_TO " << endpoint << dendl; curl_easy_setopt(easy_handle, CURLOPT_CONNECT_TO, req_data->connect_to_slist); } } diff --git a/src/rgw/rgw_http_client.h b/src/rgw/rgw_http_client.h index 942824aa9d6..5aec7c40684 100644 --- a/src/rgw/rgw_http_client.h +++ b/src/rgw/rgw_http_client.h @@ -75,6 +75,17 @@ public: void append_to_url(const std::string& suffix) { url.append(suffix); } + + friend std::ostream& operator<<(std::ostream& os, const RGWEndpoint& ep) { + os << "RGWEndpoint: url=" << ep.url; + if (!ep.original_url.empty() && ep.original_url != ep.url) { + os << " original_url=" << ep.original_url; + } + if (!ep.connect_to.empty()) { + os << " connect_to=" << ep.connect_to; + } + return os; + } }; class RGWHTTPClient : public RGWIOProvider, diff --git a/src/rgw/rgw_rest_client.cc b/src/rgw/rgw_rest_client.cc index 9317587ac78..e82680e7b0e 100644 --- a/src/rgw/rgw_rest_client.cc +++ b/src/rgw/rgw_rest_client.cc @@ -702,7 +702,8 @@ void RGWRESTStreamS3PutObj::send_init(const rgw_obj& obj) //do not encode slash in object key name url_encode(resource_str, resource, false); - ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_endpoint.get_url() << dendl; + ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource + << " , new_host = " << new_host << " , new_endpoint = " << new_endpoint << dendl; method = "PUT"; headers_gen.init(method, new_host, resource_prefix, new_endpoint, resource, params, api_name); @@ -858,7 +859,9 @@ int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAc headers_gen.emplace(cct, &new_env, &new_info); - ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource << " , new_host = " << new_host << " , new_url = " << new_endpoint.get_url() << " , new_resource = " << new_resource << dendl; + ldpp_dout(this, 20) << __func__ << "(): host = " << host << " , resource = " << resource + << " , new_host = " << new_host << " , new_endpoint = " << new_endpoint + << " , new_resource = " << new_resource << dendl; headers_gen->init(method, new_host, resource_prefix, new_endpoint, new_resource, params, api_name); diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 6c86d4e29fd..cdc8a2f6cea 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -204,7 +204,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; if (diff >= CONN_STATUS_EXPIRE_SECS) { resolved_endpoints[ep_url].status.store(ceph::real_clock::zero()); - ldout(cct, 10) << "endpoint " << endpoint.get_url() << " unconnectable status expired. mark it connectable" << dendl; + ldout(cct, 10) << endpoint << " unconnectable status expired. mark it connectable" << dendl; break; } num++; @@ -216,8 +216,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) } get_connect_to_mapping_for_url(endpoint); - ldout(cct, 20) << "get_endpoint picked endpoint url=" << endpoint.get_url() - << " connect_to=" << endpoint.get_connect_to() << dendl; + ldout(cct, 20) << "get_endpoint picked " << endpoint << dendl; return 0; } @@ -234,8 +233,8 @@ void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) const string& orig_url = endpoint.get_original_url(); if (orig_url.empty() || resolved_endpoints.find(orig_url) == resolved_endpoints.end()) { - ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status. " - << " original_url=" << orig_url << " current_url=" << endpoint.get_url() << dendl; + ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status: " + << endpoint << dendl; return; } diff --git a/src/test/rgw/CMakeLists.txt b/src/test/rgw/CMakeLists.txt index c587b5834dc..b3ebfb1a309 100644 --- a/src/test/rgw/CMakeLists.txt +++ b/src/test/rgw/CMakeLists.txt @@ -469,3 +469,9 @@ target_link_libraries(unittest_rgw_async_utils ${rgw_libs} ${UNITTEST_LIBS}) add_executable(unittest_rgw_tag test_rgw_tag.cc) add_ceph_unittest(unittest_rgw_tag) target_link_libraries(unittest_rgw_tag ${rgw_libs} ${UNITTEST_LIBS}) + +# unittest_rgw_http_client +add_executable(unittest_rgw_http_client test_rgw_http_client.cc) +add_ceph_unittest(unittest_rgw_http_client) +target_link_libraries(unittest_rgw_http_client + rgw_common ${rgw_libs} ${UNITTEST_LIBS}) diff --git a/src/test/rgw/test_rgw_http_client.cc b/src/test/rgw/test_rgw_http_client.cc new file mode 100644 index 00000000000..79f80c67edb --- /dev/null +++ b/src/test/rgw/test_rgw_http_client.cc @@ -0,0 +1,132 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab ft=cpp + +#include +#include + +#include "rgw_http_client.h" + +using namespace std; + +// Tests for RGWEndpoint + +TEST(RGWEndpointTest, default_constructor) { + RGWEndpoint ep; + EXPECT_TRUE(ep.get_url().empty()); + EXPECT_TRUE(ep.get_original_url().empty()); + EXPECT_TRUE(ep.get_connect_to().empty()); +} + +TEST(RGWEndpointTest, constructor_sets_url_and_original_url) { + RGWEndpoint ep("http://example.com:8080"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080"); + EXPECT_EQ(ep.get_original_url(), "http://example.com:8080"); +} + +TEST(RGWEndpointTest, constructor_with_connect_to) { + RGWEndpoint ep("http://example.com:8080", "example.com:8080:192.168.1.1:8080"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080"); + EXPECT_EQ(ep.get_original_url(), "http://example.com:8080"); + EXPECT_EQ(ep.get_connect_to(), "example.com:8080:192.168.1.1:8080"); +} + +TEST(RGWEndpointTest, set_url_on_default_constructed_sets_original) { + RGWEndpoint ep; + EXPECT_TRUE(ep.get_original_url().empty()); + + ep.set_url("http://first.example.com"); + EXPECT_EQ(ep.get_url(), "http://first.example.com"); + EXPECT_EQ(ep.get_original_url(), "http://first.example.com"); + + // Second set_url should NOT change original_url + ep.set_url("http://second.example.com"); + EXPECT_EQ(ep.get_url(), "http://second.example.com"); + EXPECT_EQ(ep.get_original_url(), "http://first.example.com"); +} + +TEST(RGWEndpointTest, set_url_does_not_change_original_after_constructor) { + RGWEndpoint ep("http://original.example.com"); + + ep.set_url("http://modified.example.com"); + EXPECT_EQ(ep.get_url(), "http://modified.example.com"); + EXPECT_EQ(ep.get_original_url(), "http://original.example.com"); +} + +TEST(RGWEndpointTest, with_url_returns_copy_with_new_url) { + RGWEndpoint ep("http://original.example.com"); + ep.set_connect_to("original.example.com:80:192.168.1.1:80"); + + RGWEndpoint ep2 = ep.with_url("http://modified.example.com"); + + // Original unchanged + EXPECT_EQ(ep.get_url(), "http://original.example.com"); + EXPECT_EQ(ep.get_original_url(), "http://original.example.com"); + + // Copy has new url but preserves original_url and connect_to + EXPECT_EQ(ep2.get_url(), "http://modified.example.com"); + EXPECT_EQ(ep2.get_original_url(), "http://original.example.com"); + EXPECT_EQ(ep2.get_connect_to(), "original.example.com:80:192.168.1.1:80"); +} + +TEST(RGWEndpointTest, set_connect_to) { + RGWEndpoint ep("http://example.com:8080"); + EXPECT_TRUE(ep.get_connect_to().empty()); + + ep.set_connect_to("example.com:8080:192.168.1.1:8080"); + EXPECT_EQ(ep.get_connect_to(), "example.com:8080:192.168.1.1:8080"); +} + +TEST(RGWEndpointTest, add_trailing_slash) { + RGWEndpoint ep("http://example.com:8080"); + ep.add_trailing_slash(); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); + + // Should not add another slash + ep.add_trailing_slash(); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); +} + +TEST(RGWEndpointTest, append_to_url) { + RGWEndpoint ep("http://example.com:8080"); + ep.append_to_url("/path/to/resource"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/path/to/resource"); +} + +// Tests for operator<< + +TEST(RGWEndpointTest, ostream_operator_url_only) { + RGWEndpoint ep("http://example.com:8080"); + std::ostringstream oss; + oss << ep; + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080"); +} + +TEST(RGWEndpointTest, ostream_operator_with_different_original_url) { + RGWEndpoint ep; + ep.set_url("http://original.example.com:8080"); + ep.set_url("http://modified.example.com:8080"); // original_url stays the same + + std::ostringstream oss; + oss << ep; + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://modified.example.com:8080 original_url=http://original.example.com:8080"); +} + +TEST(RGWEndpointTest, ostream_operator_with_connect_to) { + RGWEndpoint ep("http://example.com:8080"); + ep.set_connect_to("example.com:8080:192.168.1.1:8080"); + + std::ostringstream oss; + oss << ep; + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080 connect_to=example.com:8080:192.168.1.1:8080"); +} + +TEST(RGWEndpointTest, ostream_operator_full) { + RGWEndpoint ep; + ep.set_url("http://original.example.com:8080"); + ep.set_url("http://192.168.1.1:8080"); + ep.set_connect_to("original.example.com:8080:192.168.1.1:8080"); + + std::ostringstream oss; + oss << ep; + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://192.168.1.1:8080 original_url=http://original.example.com:8080 connect_to=original.example.com:8080:192.168.1.1:8080"); +} From 3e310e0c859c4a67296cacfaa9a8849f88789469 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Mon, 2 Mar 2026 18:11:27 +0000 Subject: [PATCH 085/596] rgw/rest: consolidate endpoint_urls and resolved_endpoints into single vector Previously RGWRESTConn stored endpoints in two data structures: - endpoint_urls: vector for ordered round-robin iteration - resolved_endpoints: unordered_map for lookup This was redundant since the URL was stored in both places. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 73 +++++++++++++++++++++------------------- src/rgw/rgw_rest_conn.h | 12 +++---- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index cdc8a2f6cea..37e9919a735 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -15,7 +15,8 @@ using namespace std; void RGWRESTConn::resolve_endpoints() { - for (auto& [ep_url, res_ep] : resolved_endpoints) { + for (auto& res_ep : resolved_endpoints) { + const std::string& ep_url = res_ep.url; // parse URL boost::system::result r = boost::urls::parse_uri(ep_url); if (r.has_error()) { @@ -82,15 +83,16 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, rgw::sal::Driver* driver, std::optional _api_name, HostStyle _host_style) : cct(_cct), - endpoint_urls(remote_endpoints.begin(), remote_endpoints.end()), remote_id(_remote_id), api_name(_api_name), host_style(_host_style) { resolved_endpoints.reserve(remote_endpoints.size()); for (const auto& ep_url : remote_endpoints) { - ResolvedEndpoint& res_ep = resolved_endpoints[ep_url]; + ResolvedEndpoint res_ep; + res_ep.url = ep_url; res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable + resolved_endpoints.push_back(std::move(res_ep)); } resolve_endpoints(); @@ -108,7 +110,6 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, std::optional _api_name, HostStyle _host_style) : cct(_cct), - endpoint_urls(remote_endpoints.begin(), remote_endpoints.end()), key(_cred), self_zone_group(_zone_group), remote_id(_remote_id), @@ -117,16 +118,17 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, { resolved_endpoints.reserve(remote_endpoints.size()); for (const auto& ep_url : remote_endpoints) { - ResolvedEndpoint& res_ep = resolved_endpoints[ep_url]; + ResolvedEndpoint res_ep; + res_ep.url = ep_url; res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable + resolved_endpoints.push_back(std::move(res_ep)); } resolve_endpoints(); } RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) : cct(other.cct), - endpoint_urls(std::move(other.endpoint_urls)), - endpoint_urls_counter(other.endpoint_urls_counter.load()), + endpoint_round_robin_counter(other.endpoint_round_robin_counter.load()), resolved_endpoints(std::move(other.resolved_endpoints)), key(std::move(other.key)), self_zone_group(std::move(other.self_zone_group)), @@ -139,8 +141,7 @@ RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) { cct = other.cct; - endpoint_urls = std::move(other.endpoint_urls); - endpoint_urls_counter = other.endpoint_urls_counter.load(); + endpoint_round_robin_counter = other.endpoint_round_robin_counter.load(); resolved_endpoints = std::move(other.resolved_endpoints); key = std::move(other.key); self_zone_group = std::move(other.self_zone_group); @@ -150,45 +151,46 @@ RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) return *this; } -void RGWRESTConn::get_connect_to_mapping_for_url(RGWEndpoint& endpoint) +ResolvedEndpoint* RGWRESTConn::find_resolved_endpoint(const std::string& url) +{ + for (auto& res_ep : resolved_endpoints) { + if (res_ep.url == url) { + return &res_ep; + } + } + return nullptr; +} + +void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& resolved_endpoint) { if (!cct->_conf->rgw_rest_conn_connect_to_resolved_ips) { return; } - std::string connect_to; - - auto it = resolved_endpoints.find(endpoint.get_url()); - if (it != resolved_endpoints.end() && !it->second.connect_to_strings.empty()) { - auto& res_ep = it->second; - size_t idx = res_ep.rr_index++; - connect_to = res_ep.connect_to_strings[idx % res_ep.connect_to_strings.size()]; + if (!resolved_endpoint.connect_to_strings.empty()) { + size_t idx = resolved_endpoint.rr_index++; + endpoint.set_connect_to(resolved_endpoint.connect_to_strings[idx % resolved_endpoint.connect_to_strings.size()]); } - - endpoint.set_connect_to(connect_to); } int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) { - if (endpoint_urls.empty()) { + if (resolved_endpoints.empty()) { ldout(cct, 0) << "ERROR: endpoints not configured for upstream zone" << dendl; return -EINVAL; } size_t num = 0; - while (num < endpoint_urls.size()) { - int i = ++endpoint_urls_counter; + size_t selected_idx = 0; + while (num < resolved_endpoints.size()) { + int i = ++endpoint_round_robin_counter; + selected_idx = i % resolved_endpoints.size(); - const string& ep_url = endpoint_urls[i % endpoint_urls.size()]; + ResolvedEndpoint& res_ep = resolved_endpoints[selected_idx]; + const std::string& ep_url = res_ep.url; endpoint.set_url(ep_url); - if (resolved_endpoints.find(ep_url) == resolved_endpoints.end()) { - ldout(cct, 1) << "ERROR: missing status for endpoint " << ep_url << dendl; - num++; - continue; - } - - const auto& upd_time = resolved_endpoints[ep_url].status.load(); + const auto& upd_time = res_ep.status.load(); if (ceph::real_clock::is_zero(upd_time)) { break; @@ -203,19 +205,19 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; if (diff >= CONN_STATUS_EXPIRE_SECS) { - resolved_endpoints[ep_url].status.store(ceph::real_clock::zero()); + res_ep.status.store(ceph::real_clock::zero()); ldout(cct, 10) << endpoint << " unconnectable status expired. mark it connectable" << dendl; break; } num++; }; - if (num == endpoint_urls.size()) { + if (num == resolved_endpoints.size()) { ldout(cct, 5) << "ERROR: no valid endpoint" << dendl; return -EINVAL; } - get_connect_to_mapping_for_url(endpoint); + populate_connect_to(endpoint, resolved_endpoints[selected_idx]); ldout(cct, 20) << "get_endpoint picked " << endpoint << dendl; return 0; @@ -232,13 +234,14 @@ void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { const string& orig_url = endpoint.get_original_url(); - if (orig_url.empty() || resolved_endpoints.find(orig_url) == resolved_endpoints.end()) { + ResolvedEndpoint* res_ep = find_resolved_endpoint(orig_url); + if (orig_url.empty() || !res_ep) { ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status: " << endpoint << dendl; return; } - resolved_endpoints[orig_url].status.store(ceph::real_clock::now()); + res_ep->status.store(ceph::real_clock::now()); ldout(cct, 10) << "set endpoint unconnectable. url=" << orig_url << dendl; } diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 29613d62820..c4a9389f1bc 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -112,9 +112,8 @@ struct ResolvedEndpoint { class RGWRESTConn { CephContext *cct; - std::vector endpoint_urls; // For ordered round-robin - std::atomic endpoint_urls_counter = { 0 }; // Round-robin counter for endpoint_urls - std::unordered_map resolved_endpoints; + std::atomic endpoint_round_robin_counter = { 0 }; // Round-robin counter for resolved_endpoints + std::vector resolved_endpoints; RGWAccessKey key; std::string self_zone_group; std::string remote_id; @@ -146,7 +145,8 @@ public: int get_endpoint(RGWEndpoint& endpoint); RGWEndpoint get_endpoint(); - const std::unordered_map& get_resolved_endpoints() const { return resolved_endpoints; } + const std::vector& get_resolved_endpoints() const { return resolved_endpoints; } + ResolvedEndpoint* find_resolved_endpoint(const std::string& url); void set_endpoint_unconnectable(const RGWEndpoint& endpoint); const std::string& get_self_zonegroup() { return self_zone_group; @@ -169,7 +169,7 @@ public: CephContext *get_ctx() { return cct; } - size_t get_endpoint_count() const { return endpoint_urls.size(); } + size_t get_endpoint_count() const { return resolved_endpoints.size(); } virtual void populate_params(param_vec_t& params, const rgw_owner* uid, const std::string& zonegroup); @@ -194,7 +194,7 @@ public: ceph::real_time *mtime, optional_yield y); /* pick an IP to 'connect-to' given the endpoint url */ - void get_connect_to_mapping_for_url(RGWEndpoint& endpoint); + void populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& res_ep); struct get_obj_params { const rgw_owner *uid{nullptr}; From afab77cae3a87bb7bfde1e08a3be4f562d189638 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 3 Mar 2026 01:05:06 +0000 Subject: [PATCH 086/596] rgw/rest: remove unused headers Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 37e9919a735..459559b4cf3 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -1,9 +1,7 @@ // -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- // vim: ts=8 sw=2 sts=2 expandtab ft=cpp -#include "rgw_zone.h" #include "rgw_rest_conn.h" -#include "rgw_http_errors.h" #include "rgw_sal.h" #include #include From 2d7bc7818c5976702cbddcb228f6ac13dcee8697 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 3 Mar 2026 00:45:59 +0000 Subject: [PATCH 087/596] rgw/rest: track connection failures per-IP instead of per-endpoint Previously, when a connection to a zone endpoint failed, the entire endpoint was marked as unavailable for a timeout period. Since we now resolve endpoints to all their IP addresses (via DNS A/AAAA records), we can be more granular: track failures at the individual IP level. Introduce ResolvedIP struct that pairs each IP's connect_to string with its own failure timestamp. When selecting an IP for a request, round-robin skips IPs that have recently failed, allowing traffic to continue flowing to healthy nodes even when some are down. An endpoint-level last_failure_time is maintained as a fast-path optimization to avoid scanning all IPs when none have failed recently. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 123 +++++++++++++++++++++++++++++---------- src/rgw/rgw_rest_conn.h | 79 +++++++++++++++++++------ 2 files changed, 152 insertions(+), 50 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 459559b4cf3..412a33d95a1 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -60,14 +60,14 @@ void RGWRESTConn::resolve_endpoints() { std::string port_str = std::to_string(res_ep.port); std::string host_port_prefix = res_ep.host + ":" + port_str + ":"; + res_ep.resolved_ips.reserve(results.size()); for (const auto& entry : results) { auto ip_str = entry.endpoint().address().to_string(); - res_ep.ips.push_back(ip_str); - res_ep.connect_to_strings.emplace_back(host_port_prefix + ip_str + ":" + port_str); + res_ep.resolved_ips.emplace_back(host_port_prefix + ip_str + ":" + port_str); ldout(cct, 2) << "endpoint_url=" << ep_url << " resolved to ip=" << ip_str << dendl; } ldout(cct, 2) << "endpoint=" << ep_url << " resolved to " - << res_ep.ips.size() << " IP addresses" << dendl; + << res_ep.resolved_ips.size() << " IP addresses" << dendl; } else { ldout(cct, 0) << "WARNING: RGWRESTConn no IP addresses found for endpoint=" << ep_url << (ec ? " err=" + ec.message() : "") << dendl; @@ -89,7 +89,6 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, rgw::sal::Driver* driver, for (const auto& ep_url : remote_endpoints) { ResolvedEndpoint res_ep; res_ep.url = ep_url; - res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable resolved_endpoints.push_back(std::move(res_ep)); } resolve_endpoints(); @@ -118,7 +117,6 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, for (const auto& ep_url : remote_endpoints) { ResolvedEndpoint res_ep; res_ep.url = ep_url; - res_ep.status.store(ceph::real_clock::zero()); // Initial status: connectable resolved_endpoints.push_back(std::move(res_ep)); } resolve_endpoints(); @@ -165,10 +163,38 @@ void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& r return; } - if (!resolved_endpoint.connect_to_strings.empty()) { - size_t idx = resolved_endpoint.rr_index++; - endpoint.set_connect_to(resolved_endpoint.connect_to_strings[idx % resolved_endpoint.connect_to_strings.size()]); + if (resolved_endpoint.resolved_ips.empty()) { + return; } + + static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; + const size_t num_ips = resolved_endpoint.resolved_ips.size(); + + // Round-robin through IPs, skipping any that are marked down + for (size_t i = 0; i < num_ips; ++i) { + size_t idx = resolved_endpoint.endpoint_ips_round_robin_counter++ % num_ips; + ResolvedIP& ip_status = resolved_endpoint.resolved_ips[idx]; + + const auto& last_fail = ip_status.last_failure.load(); + if (ceph::real_clock::is_zero(last_fail)) { + endpoint.set_connect_to(ip_status.connect_to); // IP is up + return; + } + + auto diff = ceph::to_seconds(ceph::real_clock::now() - last_fail); + if (diff >= CONN_STATUS_EXPIRE_SECS) { + // Failure expired, mark IP as up and use it + ip_status.mark_up(); + ldout(cct, 5) << "IP " << ip_status.connect_to << " failure expired, marking up" << dendl; + endpoint.set_connect_to(ip_status.connect_to); + return; + } + } + + // All IPs are down - do not populate connect_to; i.e., + // let libcurl handle it without connect_to hint. + ldout(cct, 5) << "All IPs down for endpoint=" << resolved_endpoint.url + << " - skip connect_to hint" << dendl; } int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) @@ -178,6 +204,37 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) return -EINVAL; } + static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; + auto now = ceph::real_clock::now(); + + // Helper to check if an endpoint has at least one available IP + auto endpoint_has_available_ip = [&](ResolvedEndpoint& res_ep) -> bool { + // If no IP resolution, endpoint is available (will use DNS directly) + if (res_ep.resolved_ips.empty()) { + return true; + } + + // Fast path: if no recent failures at endpoint level, all IPs are available + const auto& ep_last_fail = res_ep.last_failure_time.load(); + if (ceph::real_clock::is_zero(ep_last_fail) || + ceph::to_seconds(now - ep_last_fail) >= CONN_STATUS_EXPIRE_SECS) { + return true; + } + + // Slow path: check individual IPs (only when there's a recent failure) + for (auto& ip_status : res_ep.resolved_ips) { + const auto& last_fail = ip_status.last_failure.load(); + if (ceph::real_clock::is_zero(last_fail)) { + return true; // This IP is up + } + auto diff = ceph::to_seconds(now - last_fail); + if (diff >= CONN_STATUS_EXPIRE_SECS) { + return true; // This IP's failure has expired + } + } + return false; // All IPs are down + }; + size_t num = 0; size_t selected_idx = 0; while (num < resolved_endpoints.size()) { @@ -185,33 +242,18 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) selected_idx = i % resolved_endpoints.size(); ResolvedEndpoint& res_ep = resolved_endpoints[selected_idx]; - const std::string& ep_url = res_ep.url; - endpoint.set_url(ep_url); - const auto& upd_time = res_ep.status.load(); - - if (ceph::real_clock::is_zero(upd_time)) { + if (endpoint_has_available_ip(res_ep)) { + endpoint.set_url(res_ep.url); break; } - auto diff = ceph::to_seconds(ceph::real_clock::now() - upd_time); - - ldout(cct, 20) << "endpoint url=" << ep_url - << " last endpoint status update time=" - << ceph::real_clock::to_double(upd_time) - << " diff=" << diff << dendl; - - static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; - if (diff >= CONN_STATUS_EXPIRE_SECS) { - res_ep.status.store(ceph::real_clock::zero()); - ldout(cct, 10) << endpoint << " unconnectable status expired. mark it connectable" << dendl; - break; - } + ldout(cct, 5) << "endpoint url=" << res_ep.url << " all IPs down, trying next" << dendl; num++; - }; + } if (num == resolved_endpoints.size()) { - ldout(cct, 5) << "ERROR: no valid endpoint" << dendl; + ldout(cct, 1) << "ERROR: no valid endpoint (all IPs down for all endpoints)" << dendl; return -EINVAL; } @@ -231,17 +273,34 @@ RGWEndpoint RGWRESTConn::get_endpoint() void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { const string& orig_url = endpoint.get_original_url(); + const string& connect_to = endpoint.get_connect_to(); ResolvedEndpoint* res_ep = find_resolved_endpoint(orig_url); if (orig_url.empty() || !res_ep) { - ldout(cct, 0) << "ERROR: endpoint is not a valid or doesn't have status: " - << endpoint << dendl; + ldout(cct, 0) << "ERROR: endpoint is not valid or not found: " + << endpoint << dendl; return; } - res_ep->status.store(ceph::real_clock::now()); + // Update endpoint-level last_failure_time for fast-path optimization + auto now = ceph::real_clock::now(); + res_ep->last_failure_time.store(now); - ldout(cct, 10) << "set endpoint unconnectable. url=" << orig_url << dendl; + // If we have a connect_to string, mark that specific IP as down as well + if (!connect_to.empty()) { + ResolvedIP* res_ip = res_ep->find_ip_status(connect_to); + if (res_ip) { + res_ip->mark_down(); + ldout(cct, 10) << "set IP unconnectable: " << connect_to << dendl; + return; + } + } + + // Fallback: mark all IPs for this endpoint as down + for (auto& res_ip : res_ep->resolved_ips) { + res_ip.mark_down(); + } + ldout(cct, 10) << "set all IPs unconnectable for endpoint url=" << orig_url << dendl; } void RGWRESTConn::populate_params(param_vec_t& params, const rgw_owner* uid, const string& zonegroup) diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index c4a9389f1bc..66c849f1a1e 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -65,48 +65,91 @@ inline param_vec_t make_param_list(const std::map *pp) return params; } +/** + * ResolvedIP - Per-IP connection status tracking. + * + * Each resolved IP address has its own failure status. An IP is considered + * "down" if last_failure is non-zero and less than CONN_STATUS_EXPIRE_SECS old. + * After the timeout, the IP becomes eligible for retry. + */ +struct ResolvedIP { + std::string connect_to; // Pre-computed "host:port:ip:port" for CURLOPT_CONNECT_TO + mutable std::atomic last_failure; + + ResolvedIP() : last_failure(ceph::real_clock::zero()) {} + + explicit ResolvedIP(std::string _connect_to) + : connect_to(std::move(_connect_to)), last_failure(ceph::real_clock::zero()) {} + + // Move & assignment operations (required because std::atomic is not movable) + ResolvedIP(ResolvedIP&& o) noexcept + : connect_to(std::move(o.connect_to)), last_failure(o.last_failure.load()) {} + + ResolvedIP& operator=(ResolvedIP&& o) noexcept { + connect_to = std::move(o.connect_to); + last_failure.store(o.last_failure.load()); + return *this; + } + + // Delete copy (std::atomic is not copyable) + ResolvedIP(const ResolvedIP&) = delete; + ResolvedIP& operator=(const ResolvedIP&) = delete; + + void mark_down() const { last_failure.store(ceph::real_clock::now()); } + void mark_up() const { last_failure.store(ceph::real_clock::zero()); } +}; + +/** + * ResolvedEndpoint - A zone endpoint URL with its resolved IP addresses. + * + * Tracks per-IP connection status. An endpoint is considered "down" only when + * ALL of its IPs are marked as failed (within the retry timeout window). + */ struct ResolvedEndpoint { std::string url; // e.g., "https://s3.abc.com:8443" std::string scheme; // e.g., "https" std::string host; // e.g., "s3.abc.com" int port = -1; // e.g., 8443 - std::vector ips; // the IPs the endpoint resolves to - std::vector connect_to_strings; // Pre-computed full connect_to strings for each IP - size_t rr_index = 0; // round-robin index for IPs + std::vector resolved_ips; // Per-IP connect_to strings with health status + mutable size_t endpoint_ips_round_robin_counter = 0; // round-robin index for IPs + mutable std::atomic last_failure_time; // most recent IP failure seen on this endpoint - /* endpoint health state: the endpoint is not able to connect if the timestamp is not real_clock::zero */ - std::atomic status; + ResolvedEndpoint() : last_failure_time(ceph::real_clock::zero()) {} - ResolvedEndpoint() = default; - - // Custom move constructor (required because std::atomic is not movable) + // Custom move constructor (required because of atomics in ResolvedIP) ResolvedEndpoint(ResolvedEndpoint&& other) noexcept : url(std::move(other.url)), scheme(std::move(other.scheme)), host(std::move(other.host)), port(other.port), - ips(std::move(other.ips)), - connect_to_strings(std::move(other.connect_to_strings)), - rr_index(other.rr_index), - status(other.status.load()) + resolved_ips(std::move(other.resolved_ips)), + endpoint_ips_round_robin_counter(other.endpoint_ips_round_robin_counter), + last_failure_time(other.last_failure_time.load()) {} - // Custom move assignment (required because std::atomic is not movable) + // Custom move assignment ResolvedEndpoint& operator=(ResolvedEndpoint&& other) noexcept { url = std::move(other.url); scheme = std::move(other.scheme); host = std::move(other.host); port = other.port; - ips = std::move(other.ips); - connect_to_strings = std::move(other.connect_to_strings); - rr_index = other.rr_index; - status.store(other.status.load()); + resolved_ips = std::move(other.resolved_ips); + endpoint_ips_round_robin_counter = other.endpoint_ips_round_robin_counter; + last_failure_time.store(other.last_failure_time.load()); return *this; } - // Delete copy operations (std::atomic is not copyable) + // Delete copy operations ResolvedEndpoint(const ResolvedEndpoint&) = delete; ResolvedEndpoint& operator=(const ResolvedEndpoint&) = delete; + + // Find IP status by connect_to string + ResolvedIP* find_ip_status(const std::string& connect_to_str) { + for (auto& ip : resolved_ips) { + if (ip.connect_to == connect_to_str) return &ip; + } + return nullptr; + } }; class RGWRESTConn From 2d8061cf7545eac0a9f9e0a5e29362e4baa7873d Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 3 Mar 2026 01:39:19 +0000 Subject: [PATCH 088/596] rgw: make CONN_STATUS_EXPIRE_SECS a cfg option Introduce a new radosgw option 'rgw_rest_conn_ip_fail_timeout_secs' to be able to set the constant CONN_STATUS_EXPIRE_SECS dynamically. Signed-off-by: Oguzhan Ozmen --- src/common/options/rgw.yaml.in | 18 ++++++++++++++++++ src/rgw/rgw_rest_conn.cc | 10 +++++----- src/rgw/rgw_rest_conn.h | 2 +- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index 2d1c4cb777f..701f45759f7 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -2080,6 +2080,24 @@ options: default: false services: - rgw + see_also: + - rgw_rest_conn_ip_fail_timeout_secs + with_legacy: true +- name: rgw_rest_conn_ip_fail_timeout_secs + desc: IP failure tracking timeout (requires rgw_rest_conn_connect_to_resolved_ips=true) + type: uint + level: advanced + long_desc: When rgw_rest_conn_connect_to_resolved_ips is enabled, RGW tracks + per-IP connection failures by remembering the timestamp of the most recent + failure. This option controls how long (in seconds) an IP address remains + marked as "failed" before RGW considers it eligible for retry. + After this timeout expires, the IP will be tried again in the normal + round-robin rotation. + default: 2 + services: + - rgw + see_also: + - rgw_rest_conn_connect_to_resolved_ips with_legacy: true - name: rgw_obj_stripe_size type: size diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 412a33d95a1..6d27faa27c5 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -167,7 +167,7 @@ void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& r return; } - static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; + const auto ip_fail_timeout = cct->_conf->rgw_rest_conn_ip_fail_timeout_secs; const size_t num_ips = resolved_endpoint.resolved_ips.size(); // Round-robin through IPs, skipping any that are marked down @@ -182,7 +182,7 @@ void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& r } auto diff = ceph::to_seconds(ceph::real_clock::now() - last_fail); - if (diff >= CONN_STATUS_EXPIRE_SECS) { + if (diff >= ip_fail_timeout) { // Failure expired, mark IP as up and use it ip_status.mark_up(); ldout(cct, 5) << "IP " << ip_status.connect_to << " failure expired, marking up" << dendl; @@ -204,7 +204,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) return -EINVAL; } - static constexpr uint32_t CONN_STATUS_EXPIRE_SECS = 2; + const auto ip_fail_timeout = cct->_conf->rgw_rest_conn_ip_fail_timeout_secs; auto now = ceph::real_clock::now(); // Helper to check if an endpoint has at least one available IP @@ -217,7 +217,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) // Fast path: if no recent failures at endpoint level, all IPs are available const auto& ep_last_fail = res_ep.last_failure_time.load(); if (ceph::real_clock::is_zero(ep_last_fail) || - ceph::to_seconds(now - ep_last_fail) >= CONN_STATUS_EXPIRE_SECS) { + ceph::to_seconds(now - ep_last_fail) >= ip_fail_timeout) { return true; } @@ -228,7 +228,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) return true; // This IP is up } auto diff = ceph::to_seconds(now - last_fail); - if (diff >= CONN_STATUS_EXPIRE_SECS) { + if (diff >= ip_fail_timeout) { return true; // This IP's failure has expired } } diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 66c849f1a1e..325a9ab7a1d 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -69,7 +69,7 @@ inline param_vec_t make_param_list(const std::map *pp) * ResolvedIP - Per-IP connection status tracking. * * Each resolved IP address has its own failure status. An IP is considered - * "down" if last_failure is non-zero and less than CONN_STATUS_EXPIRE_SECS old. + * "down" if last_failure is non-zero and less than rgw_rest_conn_ip_fail_timeout_secs old. * After the timeout, the IP becomes eligible for retry. */ struct ResolvedIP { From 11aa2c86cd341c9a73978458426f4ebf093798e4 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 3 Mar 2026 16:49:21 +0000 Subject: [PATCH 089/596] rgw/zone: increase visibility into zone connections via admin socket Adds a new admin socket command to dump zone connection details including endpoints, resolved IPs, and health status. Useful for debugging multisite connectivity issues. Usage: ceph daemon zone connections Signed-off-by: Oguzhan Ozmen --- src/rgw/services/svc_zone.cc | 192 +++++++++++++++++++++++++++++++++++ src/rgw/services/svc_zone.h | 3 + 2 files changed, 195 insertions(+) diff --git a/src/rgw/services/svc_zone.cc b/src/rgw/services/svc_zone.cc index 055a5f6fe69..88102761333 100644 --- a/src/rgw/services/svc_zone.cc +++ b/src/rgw/services/svc_zone.cc @@ -2,6 +2,7 @@ // vim: ts=8 sw=2 sts=2 expandtab ft=cpp #include "svc_zone.h" +#include "common/admin_socket.h" #include "svc_sys_obj.h" #include "svc_sync_modules.h" @@ -20,6 +21,188 @@ using namespace std; using namespace rgw_zone_defaults; + +// Admin socket hook for zone connection information +class RGWSI_Zone_ASocketHook : public AdminSocketHook { + RGWSI_Zone *svc; + + static constexpr std::string_view admin_commands[][2] = { + { + "zone connections", + "zone connections: list zone connections with endpoints and IP health status" + } + }; + +public: + RGWSI_Zone_ASocketHook(RGWSI_Zone *_svc) : svc(_svc) {} + + int start(); + void shutdown(); + + int call(std::string_view command, const cmdmap_t& cmdmap, + const bufferlist&, + Formatter *f, + std::ostream& ss, + bufferlist& out) override; +}; + +int RGWSI_Zone_ASocketHook::start() +{ + auto admin_socket = svc->ctx()->get_admin_socket(); + for (auto cmd : admin_commands) { + int r = admin_socket->register_command(cmd[0], this, cmd[1]); + if (r < 0) { + ldout(svc->ctx(), 0) << "ERROR: fail to register admin socket command (r=" + << r << ")" << dendl; + return r; + } + } + return 0; +} + +void RGWSI_Zone_ASocketHook::shutdown() +{ + auto admin_socket = svc->ctx()->get_admin_socket(); + admin_socket->unregister_commands(this); +} + +static void dump_resolved_ip(Formatter *f, const ResolvedIP& ip, + double timeout_secs) { + f->open_object_section("ip"); + f->dump_string("connect_to", ip.connect_to); + auto last_fail = ip.last_failure.load(); + if (ceph::real_clock::is_zero(last_fail)) { + f->dump_string("status", "up"); + f->dump_string("last_failure", ""); + } else { + // Check if failure has expired based on timeout + auto now = ceph::real_clock::now(); + auto diff = ceph::to_seconds(now - last_fail); + if (diff >= timeout_secs) { + f->dump_string("status", "retry-ready"); // Timeout expired, eligible for retry but unknown if actually up + } else { + f->dump_string("status", "down"); + } + f->dump_string("last_failure", ceph::to_iso_8601(last_fail)); + } + f->close_section(); +} + +static void dump_resolved_endpoint(Formatter *f, const ResolvedEndpoint& ep, + double timeout_secs) { + f->open_object_section("endpoint"); + f->dump_string("url", ep.url); + f->dump_string("scheme", ep.scheme); + f->dump_string("host", ep.host); + f->dump_int("port", ep.port); + f->dump_string("last_failure_time", + ceph::real_clock::is_zero(ep.last_failure_time.load()) + ? "" : ceph::to_iso_8601(ep.last_failure_time.load())); + + f->open_array_section("resolved_ips"); + for (const auto& ip : ep.resolved_ips) { + dump_resolved_ip(f, ip, timeout_secs); + } + f->close_section(); + + f->close_section(); +} + +static void dump_rest_conn(Formatter *f, const std::string& name, + RGWRESTConn* conn) { + if (!conn) return; + + f->open_object_section(name); + f->dump_string("remote_id", conn->get_remote_id()); + f->dump_unsigned("endpoint_count", conn->get_endpoint_count()); + + double timeout_secs = conn->get_ctx()->_conf->rgw_rest_conn_ip_fail_timeout_secs; + + const auto& endpoints = conn->get_resolved_endpoints(); + f->open_array_section("endpoints"); + for (const auto& ep : endpoints) { + dump_resolved_endpoint(f, ep, timeout_secs); + } + f->close_section(); + + f->close_section(); +} + +int RGWSI_Zone_ASocketHook::call( + std::string_view command, const cmdmap_t& cmdmap, + const bufferlist&, + Formatter *f, + std::ostream& ss, + bufferlist& out) +{ + if (command == "zone connections"sv) { + f->open_object_section("zone_connections"); + + f->dump_string("current_time", ceph::to_iso_8601(ceph::real_clock::now())); + f->dump_string("current_zone_id", svc->zone_id().id); + f->dump_string("current_zone_name", svc->zone_name()); + + auto* master_conn = svc->get_master_conn(); + if (master_conn) { + dump_rest_conn(f, "master_conn", master_conn); + } + + // Zone connections map + auto& zone_conn_map = svc->get_zone_conn_map(); + f->open_object_section("zone_conn_map"); + for (auto& [zone_id, conn] : zone_conn_map) { + dump_rest_conn(f, zone_id.id, conn); + } + f->close_section(); + + // Only show zonegroup_conn_map if it has connections beyond the master + auto& zonegroup_conn_map = svc->get_zonegroup_conn_map(); + bool zg_differs_from_master = false; + if (!master_conn) { + zg_differs_from_master = !zonegroup_conn_map.empty(); + } else if (zonegroup_conn_map.size() > 1) { + zg_differs_from_master = true; + } else if (zonegroup_conn_map.size() == 1) { + auto& [zg_name, conn] = *zonegroup_conn_map.begin(); + zg_differs_from_master = (conn->get_remote_id() != master_conn->get_remote_id()); + } + if (zg_differs_from_master) { + f->open_object_section("zonegroup_conn_map"); + for (auto& [zg_name, conn] : zonegroup_conn_map) { + dump_rest_conn(f, zg_name, conn); + } + f->close_section(); + } + + // Only show zone_data_notify_to_map if it differs from zone_conn_map + auto& notify_map = svc->get_zone_data_notify_to_map(); + bool notify_differs = false; + if (notify_map.size() != zone_conn_map.size()) { + notify_differs = true; + } else { + for (auto& [zone_id, conn] : notify_map) { + auto it = zone_conn_map.find(zone_id); + if (it == zone_conn_map.end() || it->second != conn) { + notify_differs = true; + break; + } + } + } + if (notify_differs) { + f->open_object_section("zone_data_notify_to_map"); + for (auto& [zone_id, conn] : notify_map) { + dump_rest_conn(f, zone_id.id, conn); + } + f->close_section(); + } + + f->close_section(); + return 0; + } + + return -ENOSYS; +} + RGWSI_Zone::RGWSI_Zone(CephContext *cct, rgw::sal::ConfigStore* _cfgstore, const rgw::SiteConfig* _site) : RGWServiceInstance(cct), cfgstore(_cfgstore), site(_site) { @@ -212,11 +395,20 @@ int RGWSI_Zone::do_start(optional_yield y, const DoutPrefixProvider *dpp) ldpp_dout(dpp, 20) << "started zone id=" << zone_params->get_id() << " (name=" << zone_params->get_name() << ") with tier type = " << zone_public_config->tier_type << dendl; + + // Initialize admin socket hook + asocket_hook = std::make_unique(this); + asocket_hook->start(); return 0; } void RGWSI_Zone::shutdown() { + // Shutdown admin socket hook + if (asocket_hook) { + asocket_hook->shutdown(); + } + delete rest_master_conn; for (auto& item : zone_conn_map) { diff --git a/src/rgw/services/svc_zone.h b/src/rgw/services/svc_zone.h index 451faac8bef..1c702ec40c8 100644 --- a/src/rgw/services/svc_zone.h +++ b/src/rgw/services/svc_zone.h @@ -4,6 +4,7 @@ #pragma once #include "driver/rados/rgw_service.h" +#include class RGWSI_SysObj; @@ -22,6 +23,7 @@ class RGWBucketSyncPolicyHandler; class RGWRESTConn; struct rgw_sync_policy_info; +class RGWSI_Zone_ASocketHook; class RGWSI_Zone : public RGWServiceInstance { @@ -57,6 +59,7 @@ class RGWSI_Zone : public RGWServiceInstance std::unique_ptr sync_policy; rgw::sal::ConfigStore *cfgstore{nullptr}; const rgw::SiteConfig* site{nullptr}; + std::unique_ptr asocket_hook; void init(RGWSI_SysObj *_sysobj_svc, librados::Rados* rados_, From 9f0c1093a002f4de94201cb54c1c733f5828e965 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 3 Mar 2026 20:46:38 +0000 Subject: [PATCH 090/596] rgw: rename round-robin counters for brevity Rename endpoint_round_robin_counter to endpoint_rr_index and endpoint_ips_round_robin_counter to ip_rr_index for shorter, cleaner variable names while maintaining clarity. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 11 ++++++----- src/rgw/rgw_rest_conn.h | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 6d27faa27c5..c0060c8fbdb 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -124,7 +124,7 @@ RGWRESTConn::RGWRESTConn(CephContext *_cct, RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) : cct(other.cct), - endpoint_round_robin_counter(other.endpoint_round_robin_counter.load()), + endpoint_rr_index(other.endpoint_rr_index.load()), resolved_endpoints(std::move(other.resolved_endpoints)), key(std::move(other.key)), self_zone_group(std::move(other.self_zone_group)), @@ -137,7 +137,7 @@ RGWRESTConn::RGWRESTConn(RGWRESTConn&& other) RGWRESTConn& RGWRESTConn::operator=(RGWRESTConn&& other) { cct = other.cct; - endpoint_round_robin_counter = other.endpoint_round_robin_counter.load(); + endpoint_rr_index = other.endpoint_rr_index.load(); resolved_endpoints = std::move(other.resolved_endpoints); key = std::move(other.key); self_zone_group = std::move(other.self_zone_group); @@ -169,10 +169,11 @@ void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& r const auto ip_fail_timeout = cct->_conf->rgw_rest_conn_ip_fail_timeout_secs; const size_t num_ips = resolved_endpoint.resolved_ips.size(); + auto now = ceph::real_clock::now(); // Round-robin through IPs, skipping any that are marked down for (size_t i = 0; i < num_ips; ++i) { - size_t idx = resolved_endpoint.endpoint_ips_round_robin_counter++ % num_ips; + size_t idx = resolved_endpoint.ip_rr_index++ % num_ips; ResolvedIP& ip_status = resolved_endpoint.resolved_ips[idx]; const auto& last_fail = ip_status.last_failure.load(); @@ -181,7 +182,7 @@ void RGWRESTConn::populate_connect_to(RGWEndpoint& endpoint, ResolvedEndpoint& r return; } - auto diff = ceph::to_seconds(ceph::real_clock::now() - last_fail); + auto diff = ceph::to_seconds(now - last_fail); if (diff >= ip_fail_timeout) { // Failure expired, mark IP as up and use it ip_status.mark_up(); @@ -238,7 +239,7 @@ int RGWRESTConn::get_endpoint(RGWEndpoint& endpoint) size_t num = 0; size_t selected_idx = 0; while (num < resolved_endpoints.size()) { - int i = ++endpoint_round_robin_counter; + int i = ++endpoint_rr_index; selected_idx = i % resolved_endpoints.size(); ResolvedEndpoint& res_ep = resolved_endpoints[selected_idx]; diff --git a/src/rgw/rgw_rest_conn.h b/src/rgw/rgw_rest_conn.h index 325a9ab7a1d..82794867e56 100644 --- a/src/rgw/rgw_rest_conn.h +++ b/src/rgw/rgw_rest_conn.h @@ -111,7 +111,7 @@ struct ResolvedEndpoint { std::string host; // e.g., "s3.abc.com" int port = -1; // e.g., 8443 std::vector resolved_ips; // Per-IP connect_to strings with health status - mutable size_t endpoint_ips_round_robin_counter = 0; // round-robin index for IPs + mutable std::atomic ip_rr_index{0}; // round-robin index for IPs mutable std::atomic last_failure_time; // most recent IP failure seen on this endpoint ResolvedEndpoint() : last_failure_time(ceph::real_clock::zero()) {} @@ -123,7 +123,7 @@ struct ResolvedEndpoint { host(std::move(other.host)), port(other.port), resolved_ips(std::move(other.resolved_ips)), - endpoint_ips_round_robin_counter(other.endpoint_ips_round_robin_counter), + ip_rr_index(other.ip_rr_index.load()), last_failure_time(other.last_failure_time.load()) {} @@ -134,7 +134,7 @@ struct ResolvedEndpoint { host = std::move(other.host); port = other.port; resolved_ips = std::move(other.resolved_ips); - endpoint_ips_round_robin_counter = other.endpoint_ips_round_robin_counter; + ip_rr_index.store(other.ip_rr_index.load()); last_failure_time.store(other.last_failure_time.load()); return *this; } @@ -155,7 +155,7 @@ struct ResolvedEndpoint { class RGWRESTConn { CephContext *cct; - std::atomic endpoint_round_robin_counter = { 0 }; // Round-robin counter for resolved_endpoints + std::atomic endpoint_rr_index = { 0 }; // Round-robin counter for resolved_endpoints std::vector resolved_endpoints; RGWAccessKey key; std::string self_zone_group; From 9eda20715c8dba181918773b59bd4648289bd66e Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 24 Mar 2026 15:52:38 +0000 Subject: [PATCH 091/596] doc/radosgw: expose rgw_rest_conn_connect_to_resolved_ips and rgw_rest_conn_ip_fail_timeout_secs Expose the confval information for the confval "rgw_rest_conn_connect_to_resolved_ips" and "rgw_rest_conn_ip_fail_timeout_secs" so that they can be seen in the "Ceph Object Gateway Config Reference" as these are meant to be client-facing configs. Signed-off-by: Oguzhan Ozmen --- doc/radosgw/config-ref.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/radosgw/config-ref.rst b/doc/radosgw/config-ref.rst index feef5f3515e..ea97a55946a 100644 --- a/doc/radosgw/config-ref.rst +++ b/doc/radosgw/config-ref.rst @@ -58,6 +58,8 @@ instances or all radosgw-admin options can be put into the ``[global]`` or the .. confval:: rgw_account_default_quota_max_objects .. confval:: rgw_account_default_quota_max_size .. confval:: rgw_verify_ssl +.. confval:: rgw_rest_conn_connect_to_resolved_ips +.. confval:: rgw_rest_conn_ip_fail_timeout_secs .. confval:: rgw_max_chunk_size .. confval:: rgw_multi_obj_del_max_aio From 7b37c21cfa6b6866a2efadbce257f9b6adad58ea Mon Sep 17 00:00:00 2001 From: "Ashwin M. Joshi" Date: Wed, 18 Feb 2026 11:19:12 +0530 Subject: [PATCH 092/596] python-common: Move validation function to utils and remove unused Fixes: https://tracker.ceph.com/issues/74986 Signed-off-by: Ashwin M. Joshi Conflicts: src/python-common/ceph/deployment/service_spec.py src/python-common/ceph/deployment/utils.py --- .../ceph/deployment/service_spec.py | 18 +++++++----------- src/python-common/ceph/deployment/utils.py | 6 ++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 86bdf18451a..b5726820ee5 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -41,7 +41,7 @@ from ceph.deployment.hostspec import ( ) from ceph.deployment.utils import unwrap_ipv6, valid_addr, verify_non_negative_int from ceph.deployment.utils import verify_positive_int, verify_non_negative_number -from ceph.deployment.utils import verify_boolean, verify_enum, verify_int +from ceph.deployment.utils import verify_boolean, verify_enum, verify_int, verify_non_empty_string from ceph.deployment.utils import parse_combined_pem_file, validate_port, validate_unique_ports from ceph.cephadm.d3n_types import D3NCacheSpec, D3NCacheError from ceph.utils import is_hex @@ -52,11 +52,6 @@ ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec') FuncT = TypeVar('FuncT', bound=Callable) -def validate_non_empty_string(value: Optional[str], field_name: str) -> None: - if not isinstance(value, str) or not value.strip(): - raise SpecValidationError(f"Invalid {field_name}: Must be a non-empty string.") - - class TLSBlock(TypedDict, total=False): ssl: bool certificate_source: str @@ -2827,15 +2822,16 @@ class OAuth2ProxySpec(ServiceSpec): + ', '.join(missing_required_fields) + '.' ) - validate_non_empty_string(self.provider_display_name, "provider_display_name") - validate_non_empty_string(self.client_id, "client_id") - validate_non_empty_string(self.client_secret, "client_secret") + verify_non_empty_string(self.provider_display_name, "provider_display_name") + verify_non_empty_string(self.client_id, "client_id") + verify_non_empty_string(self.client_secret, "client_secret") + self._validate_cookie_secret(self.cookie_secret) self._validate_url(self.oidc_issuer_url, "oidc_issuer_url") if self.redirect_url is not None: self._validate_url(self.redirect_url, "redirect_url") if self.scope is not None: - self._validate_non_empty_string(self.scope, "scope") + verify_non_empty_string(self.scope, "scope") if self.email_domains is not None: self._validate_domain_name(self.email_domains, "email_domains") if self.https_address is not None: @@ -3657,7 +3653,7 @@ class TunedProfileSpec(): if 'profile_name' not in spec: raise SpecValidationError('Tuned profile spec must include "profile_name" field') data['profile_name'] = spec['profile_name'] - validate_non_empty_string(data['profile_name'], "profile_name") + verify_non_empty_string(data['profile_name'], "profile_name") if 'placement' in spec: data['placement'] = PlacementSpec.from_json(spec['placement']) if 'settings' in spec: diff --git a/src/python-common/ceph/deployment/utils.py b/src/python-common/ceph/deployment/utils.py index 3e901669f5c..52a53ea5b4f 100644 --- a/src/python-common/ceph/deployment/utils.py +++ b/src/python-common/ceph/deployment/utils.py @@ -191,3 +191,9 @@ def validate_unique_ports(ports: List[int]) -> None: raise SpecValidationError( 'Invalid port: Duplicate ports are not allowed' ) + + +def verify_non_empty_string(field: Any, field_name: str) -> None: + # isinstance first so we never call .strip() on None or non-str + if not isinstance(field, str) or not field.strip(): + raise SpecValidationError(f"Invalid {field_name}: Must be a non-empty string.") From 05a28d1b635ca3d7e047545eec986b9bdade5a72 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sat, 25 Apr 2026 11:54:16 +0800 Subject: [PATCH 093/596] ceph-dencoder: skip dlclose under ASan so leaks symbolise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DencoderPlugin destructor calls dlclose() on each loaded plugin .so before main() returns. When LeakSanitizer's atexit-registered leak check then runs, the plugin .so is no longer mapped in the process address space, and any leak whose allocation stack passes through plugin code is reported with "" — useless for investigation. This is not an ASan bug or a missing -g flag; it is the unavoidable ordering of dlclose-before-leak-check. Skipping dlclose under __SANITIZE_ADDRESS__ leaves the plugins mapped until process exit, restoring symbolisation. The functional cost is zero: the kernel will unmap the .so segments when the process exits anyway. Without this, a developer triaging dencoder leaks under ASan has no visibility into which plugin or which static initialiser is responsible. With this, the leak stacks resolve to real symbols and real bugs become triagable. This commit does not change leak-detection results in non-sanitiser builds and is a no-op on FreeBSD (which already skipped dlclose). Signed-off-by: Kefu Chai --- src/tools/ceph-dencoder/denc_plugin.h | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tools/ceph-dencoder/denc_plugin.h b/src/tools/ceph-dencoder/denc_plugin.h index c5eacce47cb..09cb1438f17 100644 --- a/src/tools/ceph-dencoder/denc_plugin.h +++ b/src/tools/ceph-dencoder/denc_plugin.h @@ -24,11 +24,23 @@ public: } ~DencoderPlugin() { unregister_dencoders(); -#if !defined(__FreeBSD__) +#if defined(__has_feature) +# if __has_feature(address_sanitizer) || __has_feature(leak_sanitizer) +# define DENC_SKIP_DLCLOSE 1 +# endif +#endif +#if defined(__SANITIZE_ADDRESS__) && !defined(DENC_SKIP_DLCLOSE) +# define DENC_SKIP_DLCLOSE 1 +#endif +#if !defined(__FreeBSD__) && !defined(DENC_SKIP_DLCLOSE) + // Skip dlclose under ASan/LSan: the leak checker at process exit needs + // the .so still mapped to resolve symbols. Clang may not define + // __SANITIZE_ADDRESS__ (e.g. clang-19), hence the __has_feature check. if (mod) { dlclose(mod); } #endif +#undef DENC_SKIP_DLCLOSE } const dencoders_t& register_dencoders() { static constexpr std::string_view REGISTER_DENCODERS_FUNCTION = "register_dencoders\0"; From e94e76f058bd1f6b1289da37dc99d57a665a03c9 Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Thu, 4 Jun 2026 09:39:06 +0000 Subject: [PATCH 094/596] crimson/os/seastore: add op_lat histogram buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialize lat_hist_bounds_us bucket boundaries (0.25ms–20ms in 10 log-ish steps) for the op_lat Seastar histogram in register_metrics(), and populate them in add_latency_sample(). Previously op_lat only tracked sample_count and sample_sum, giving an average but no percentile visibility. Signed-off-by: Matan Breizman --- src/crimson/os/seastore/seastore.cc | 8 ++++++++ src/crimson/os/seastore/seastore.h | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index 68f0e310924..f66fc9fd964 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -178,6 +178,14 @@ void SeaStore::Shard::register_metrics(store_index_t store_index) {op_type_t::OMAP_ITERATE, sm::label_instance("latency", "OMAP_ITERATE")}, }; + for (auto& hist : stats.op_lat) { + hist.buckets.resize(lat_hist_bounds_us.size()); + for (std::size_t i = 0; i < lat_hist_bounds_us.size(); ++i) { + hist.buckets[i].upper_bound = lat_hist_bounds_us[i]; + hist.buckets[i].count = 0; + } + } + for (auto& [op_type, label] : labels_by_op_type) { auto desc = fmt::format("latency of seastore operation (optype={})", op_type); diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index 4b356f4304a..eae23b4525a 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -425,6 +425,19 @@ public: static constexpr auto LAT_MAX = static_cast(op_type_t::MAX); + // Histogram bucket upper bounds in microseconds (0.25ms–20ms). + // Ops above 20ms land in the last bucket as overflow. + static constexpr std::array lat_hist_bounds_us = { + 250, 500, 1000, + 1500, 2000, 3000, + 5000, + 7500, 10000, + 15000, 20000, + 30000, 50000, + 100000 + }; + + struct { std::array op_lat; } stats; @@ -440,6 +453,17 @@ public: seastar::metrics::histogram& lat = get_latency(op_type); lat.sample_count++; lat.sample_sum += std::chrono::duration_cast(dur).count(); + bool found = false; + for (auto& b : lat.buckets) { + if (static_cast(std::chrono::duration_cast(dur).count()) <= b.upper_bound) { + ++b.count; + found = true; + break; + } + } + if (!found && !lat.buckets.empty()) { + ++lat.buckets.back().count; + } } /* From 18562abadef0bf64d7a106c5a3ec540127c60e87 Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Thu, 4 Jun 2026 09:39:11 +0000 Subject: [PATCH 095/596] crimson/os/seastore: switch op_lat sample_sum from ms to us Sub-ms ops rounded to zero under the old millisecond cast, making the average meaningless for fast NVMe workloads. sample_sum is now in microseconds; divide by 1000 for ms average. Signed-off-by: Matan Breizman --- src/crimson/os/seastore/seastore.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index eae23b4525a..54dd67c783f 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -451,11 +451,12 @@ public: void add_latency_sample(op_type_t op_type, std::chrono::steady_clock::duration dur) { seastar::metrics::histogram& lat = get_latency(op_type); + auto us = std::chrono::duration_cast(dur).count(); lat.sample_count++; - lat.sample_sum += std::chrono::duration_cast(dur).count(); + lat.sample_sum += us; bool found = false; for (auto& b : lat.buckets) { - if (static_cast(std::chrono::duration_cast(dur).count()) <= b.upper_bound) { + if (static_cast(us) <= b.upper_bound) { ++b.count; found = true; break; From 47c6618d8138928abf5ce337d0f0fb3b91de02ef Mon Sep 17 00:00:00 2001 From: Matt Benjamin Date: Wed, 27 May 2026 00:14:17 -0400 Subject: [PATCH 096/596] rgwlc: faithfully store lifecycle filters Fix the bug where an empty lifecycle filter was stored identically to one with an empty old-format prefix rule-- which caused the recovered rules to be formed incorrectly as prefix rules. Found by Kyle Bader and Tekton workflow. Updated to not declare virtual methods in LCFilter. Fixes: https://tracker.ceph.com/issues/76806 Signed-off-by: Matt Benjamin --- src/rgw/rgw_lc.h | 21 +++++++++++++++------ src/rgw/rgw_lc_s3.cc | 2 ++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/rgw/rgw_lc.h b/src/rgw/rgw_lc.h index b4cab41916d..85570dd0c5f 100644 --- a/src/rgw/rgw_lc.h +++ b/src/rgw/rgw_lc.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include #include @@ -176,6 +177,7 @@ enum class LCFlagType : uint16_t { none = 0, ArchiveZone, + Filter, }; class LCFlag { @@ -201,11 +203,11 @@ class LCFilter } } - static constexpr std::array filter_flags = - { - LCFlag(LCFlagType::none, "none"), - LCFlag(LCFlagType::ArchiveZone, "ArchiveZone"), - }; + static constexpr std::array filter_flags = { + LCFlag(LCFlagType::none, "none"), + LCFlag(LCFlagType::ArchiveZone, "ArchiveZone"), + LCFlag(LCFlagType::Filter, "Filter"), + }; protected: std::string prefix; @@ -217,7 +219,10 @@ protected: public: LCFilter() : flags(make_flag(LCFlagType::none)) - {} + {} + + ~LCFilter() + {} const std::string& get_prefix() const { return prefix; @@ -231,6 +236,10 @@ public: return flags; } + void set_flag(LCFlagType flag) { + flags |= make_flag(flag); + } + bool empty() const { return !(has_prefix() || has_tags() || has_flags() || has_size_rule()); diff --git a/src/rgw/rgw_lc_s3.cc b/src/rgw/rgw_lc_s3.cc index eb8ff22ec5b..b84ba5a80c9 100644 --- a/src/rgw/rgw_lc_s3.cc +++ b/src/rgw/rgw_lc_s3.cc @@ -255,6 +255,8 @@ void LCRule_S3::decode_xml(XMLObj *obj) if (!RGWXMLDecoder::decode_xml("Prefix", prefix, obj)) { throw RGWXMLDecoder::err("missing Prefix in Filter"); } + } else { + filter_s3.set_flag(LCFlagType::Filter); } filter = (LCFilter)filter_s3; From 3513cd888a0e6c6c879ef42823255c2e620a8fca Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 14 Apr 2026 16:46:05 +0000 Subject: [PATCH 097/596] rgw: store RGWEndpoint URL as boost::urls::url Replace raw string manipulation in RGWEndpoint with boost::urls::url. URL path/query/host changes now use set_path(), set_query(), set_host() instead of string concatenation. Rename original_url to endpoint_url_lookup_id to clarify its role as a health-tracking key for ResolvedEndpoint lookup in set_endpoint_unconnectable(). Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_http_client.cc | 2 +- src/rgw/rgw_http_client.h | 94 ++++++++++++--------- src/rgw/rgw_rest_client.cc | 23 ++--- src/rgw/rgw_rest_conn.cc | 8 +- src/test/rgw/test_rgw_http_client.cc | 122 ++++++++++++++++----------- 5 files changed, 139 insertions(+), 110 deletions(-) diff --git a/src/rgw/rgw_http_client.cc b/src/rgw/rgw_http_client.cc index 1b1d5bae8b7..e2d9d87180c 100644 --- a/src/rgw/rgw_http_client.cc +++ b/src/rgw/rgw_http_client.cc @@ -333,7 +333,7 @@ void RGWHTTPClient::init() } } - const string& url = endpoint.get_url(); + const string url = endpoint.get_url(); auto pos = url.find("://"); if (pos == string::npos) { diff --git a/src/rgw/rgw_http_client.h b/src/rgw/rgw_http_client.h index 5aec7c40684..16f73ad23d6 100644 --- a/src/rgw/rgw_http_client.h +++ b/src/rgw/rgw_http_client.h @@ -10,6 +10,7 @@ #include "rgw_http_client_types.h" #include +#include using param_pair_t = std::pair; using param_vec_t = std::vector; @@ -23,21 +24,22 @@ class RGWHTTPManager; /** * RGWEndpoint - Represents an HTTP endpoint with additional metdata such as connection routing. * - * Unlike a plain URL string, RGWEndpoint carries additional information needed - * to leverage libcurl's CURLOPT_CONNECT_TO option. This enables RGW to route - * requests to specific IP addresses while preserving the original hostname for - * TLS/SNI and Host headers. + * Wraps boost::urls::url for proper URL manipulation (set_path, set_query, + * set_host) instead of raw string concatenation. + * + * Carries a CURLOPT_CONNECT_TO override for IP-level routing. * * Fields: - * - url: The effective URL for the request (may be modified with paths). - * - original_url: The initially configured endpoint URL (preserved for reference). - * - connect_to: libcurl CONNECT_TO string (format: "host:port:addr:port") to - * override connection routing without changing the request URL. + * - endpoint_url_lookup_id: The initially configured endpoint URL (immutable). + * Used as a lookup key in RGWRESTConn to find the ResolvedEndpoint + * for health tracking (marking IPs as up/down). + * - url: boost::urls::url for the request URL (mutable via set_path, etc.). + * - connect_to: libcurl CONNECT_TO string (format: "host:port:addr:port"). */ struct RGWEndpoint { private: - std::string url; - std::string original_url; + std::string endpoint_url_lookup_id; + boost::urls::url url; std::string connect_to; public: @@ -45,45 +47,57 @@ public: RGWEndpoint(const char* u) : RGWEndpoint(std::string(u)) {} - RGWEndpoint(std::string u, std::string c = {}) - : url(std::move(u)), original_url(url), connect_to(std::move(c)) {} - - RGWEndpoint with_url(std::string new_url) const { - RGWEndpoint e = *this; - e.set_url(std::move(new_url)); - return e; - } - - void set_url(const std::string& _url) { - url = _url; - // Capture the first URL assignment as the original - if (original_url.empty()) { - original_url = _url; + RGWEndpoint(const std::string& u) : endpoint_url_lookup_id(u) { + auto r = boost::urls::parse_uri(u); + if (r.has_value()) { + url = r.value(); } } - const std::string& get_url() const { return url; } - const std::string& get_original_url() const { return original_url; } - void set_connect_to(const std::string& _connect_to) { connect_to = _connect_to; } - const std::string& get_connect_to() const { return connect_to; } + RGWEndpoint(const std::string& u, const std::string& c) + : endpoint_url_lookup_id(u), connect_to(c) { + auto r = boost::urls::parse_uri(u); + if (r.has_value()) { + url = r.value(); + } + } + + void set_url(const std::string& u) { + auto r = boost::urls::parse_uri(u); + if (r.has_value()) { + url = r.value(); + } + if (endpoint_url_lookup_id.empty()) { + endpoint_url_lookup_id = u; + } + } + + std::string get_url() const { return std::string(url.buffer()); } + const std::string& get_endpoint_url_lookup_id() const { return endpoint_url_lookup_id; } + + void set_path(const std::string& path) { url.set_encoded_path(path); } + void set_query(const std::string& q) { + if (q.empty()) return; + // strip leading '?' - if present, boost::urls adds it automatically + url.set_encoded_query(q[0] == '?' ? q.substr(1) : q); + } + void set_host(const std::string& h) { url.set_host(h); } void add_trailing_slash() { - if (! url.empty() && url.back() != '/') - append_to_url("/"); + auto path = std::string(url.encoded_path()); + if (path.empty() || path.back() != '/') { + path += '/'; + url.set_encoded_path(path); + } } - void append_to_url(const std::string& suffix) { - url.append(suffix); - } + void set_connect_to(const std::string& c) { connect_to = c; } + const std::string& get_connect_to() const { return connect_to; } friend std::ostream& operator<<(std::ostream& os, const RGWEndpoint& ep) { - os << "RGWEndpoint: url=" << ep.url; - if (!ep.original_url.empty() && ep.original_url != ep.url) { - os << " original_url=" << ep.original_url; - } - if (!ep.connect_to.empty()) { - os << " connect_to=" << ep.connect_to; - } + os << "RGWEndpoint: url=" << ep.url.buffer() + << " endpoint_url_lookup_id=" << (ep.endpoint_url_lookup_id.empty() ? "" : ep.endpoint_url_lookup_id) + << " connect_to=" << (ep.connect_to.empty() ? "" : ep.connect_to); return os; } }; diff --git a/src/rgw/rgw_rest_client.cc b/src/rgw/rgw_rest_client.cc index e82680e7b0e..02b80c2f1ed 100644 --- a/src/rgw/rgw_rest_client.cc +++ b/src/rgw/rgw_rest_client.cc @@ -425,16 +425,8 @@ auto RGWRESTSimpleRequest::forward_request(const DoutPrefixProvider *dpp, const string params_str; get_params_str(new_info.args.get_params(), params_str); - string new_url = endpoint.get_url(); - string& resource = new_info.request_uri; - string new_resource = resource; - if (new_url[new_url.size() - 1] == '/' && resource[0] == '/') { - new_url = new_url.substr(0, new_url.size() - 1); - } else if (resource[0] != '/') { - new_resource = "/"; - new_resource.append(resource); - } - new_url.append(new_resource + params_str); + endpoint.set_path(new_info.request_uri); + endpoint.set_query(params_str); bufferlist::iterator bliter; @@ -446,7 +438,6 @@ auto RGWRESTSimpleRequest::forward_request(const DoutPrefixProvider *dpp, const } method = new_info.method; - endpoint.set_url(new_url); std::ignore = process(dpp, y); @@ -581,7 +572,9 @@ void RGWRESTGenerateHTTPHeaders::init(const string& _method, const string& host, url_encode(iter->second, encode_slash)); } - endpoint = _endpoint.with_url(_endpoint.get_url() + resource + params_str); + endpoint = _endpoint; + endpoint.set_path(resource); + endpoint.set_query(params_str); const std::string date_str = get_gmt_date_str(); new_env->set("HTTP_DATE", date_str.c_str()); @@ -692,8 +685,8 @@ void RGWRESTStreamS3PutObj::send_init(const rgw_obj& obj) if (host_style == VirtualStyle) { resource_str = obj.get_oid(); - new_endpoint.set_url(protocol + "://" + bucket_name + "." + host); new_host = bucket_name + "." + new_host; + new_endpoint.set_host(new_host); } else { resource_str = bucket_name + "/" + obj.get_oid(); } @@ -847,13 +840,13 @@ int RGWRESTStreamRWRequest::do_send_prepare(const DoutPrefixProvider *dpp, RGWAc } if (host_style == VirtualStyle) { - new_endpoint.set_url(protocol + "://" + bucket_name + "." + host); + new_host = bucket_name + "." + host; + new_endpoint.set_host(new_host); if(pos == string::npos) { new_resource = ""; } else { new_resource = new_resource.substr(pos+1); } - new_host = bucket_name + "." + host; } new_endpoint.add_trailing_slash(); diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index c0060c8fbdb..46f6685c6fd 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -273,11 +273,11 @@ RGWEndpoint RGWRESTConn::get_endpoint() void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) { - const string& orig_url = endpoint.get_original_url(); + const string& endpoint_id = endpoint.get_endpoint_url_lookup_id(); const string& connect_to = endpoint.get_connect_to(); - ResolvedEndpoint* res_ep = find_resolved_endpoint(orig_url); - if (orig_url.empty() || !res_ep) { + ResolvedEndpoint* res_ep = find_resolved_endpoint(endpoint_id); + if (endpoint_id.empty() || !res_ep) { ldout(cct, 0) << "ERROR: endpoint is not valid or not found: " << endpoint << dendl; return; @@ -301,7 +301,7 @@ void RGWRESTConn::set_endpoint_unconnectable(const RGWEndpoint& endpoint) for (auto& res_ip : res_ep->resolved_ips) { res_ip.mark_down(); } - ldout(cct, 10) << "set all IPs unconnectable for endpoint url=" << orig_url << dendl; + ldout(cct, 10) << "set all IPs unconnectable for endpoint=" << endpoint << dendl; } void RGWRESTConn::populate_params(param_vec_t& params, const rgw_owner* uid, const string& zonegroup) diff --git a/src/test/rgw/test_rgw_http_client.cc b/src/test/rgw/test_rgw_http_client.cc index 79f80c67edb..33cb2d1f85f 100644 --- a/src/test/rgw/test_rgw_http_client.cc +++ b/src/test/rgw/test_rgw_http_client.cc @@ -13,59 +13,98 @@ using namespace std; TEST(RGWEndpointTest, default_constructor) { RGWEndpoint ep; EXPECT_TRUE(ep.get_url().empty()); - EXPECT_TRUE(ep.get_original_url().empty()); + EXPECT_TRUE(ep.get_endpoint_url_lookup_id().empty()); EXPECT_TRUE(ep.get_connect_to().empty()); } -TEST(RGWEndpointTest, constructor_sets_url_and_original_url) { +TEST(RGWEndpointTest, constructor_sets_url_and_lookup_id) { RGWEndpoint ep("http://example.com:8080"); EXPECT_EQ(ep.get_url(), "http://example.com:8080"); - EXPECT_EQ(ep.get_original_url(), "http://example.com:8080"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); } TEST(RGWEndpointTest, constructor_with_connect_to) { RGWEndpoint ep("http://example.com:8080", "example.com:8080:192.168.1.1:8080"); EXPECT_EQ(ep.get_url(), "http://example.com:8080"); - EXPECT_EQ(ep.get_original_url(), "http://example.com:8080"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); EXPECT_EQ(ep.get_connect_to(), "example.com:8080:192.168.1.1:8080"); } -TEST(RGWEndpointTest, set_url_on_default_constructed_sets_original) { +TEST(RGWEndpointTest, set_url_on_default_constructed_sets_base) { RGWEndpoint ep; - EXPECT_TRUE(ep.get_original_url().empty()); + EXPECT_TRUE(ep.get_endpoint_url_lookup_id().empty()); ep.set_url("http://first.example.com"); EXPECT_EQ(ep.get_url(), "http://first.example.com"); - EXPECT_EQ(ep.get_original_url(), "http://first.example.com"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://first.example.com"); - // Second set_url should NOT change original_url + // Second set_url should NOT change endpoint_url_lookup_id ep.set_url("http://second.example.com"); EXPECT_EQ(ep.get_url(), "http://second.example.com"); - EXPECT_EQ(ep.get_original_url(), "http://first.example.com"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://first.example.com"); } -TEST(RGWEndpointTest, set_url_does_not_change_original_after_constructor) { - RGWEndpoint ep("http://original.example.com"); - - ep.set_url("http://modified.example.com"); - EXPECT_EQ(ep.get_url(), "http://modified.example.com"); - EXPECT_EQ(ep.get_original_url(), "http://original.example.com"); +TEST(RGWEndpointTest, set_path) { + RGWEndpoint ep("http://example.com:8080"); + ep.set_path("/path/to/resource"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/path/to/resource"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); } -TEST(RGWEndpointTest, with_url_returns_copy_with_new_url) { - RGWEndpoint ep("http://original.example.com"); - ep.set_connect_to("original.example.com:80:192.168.1.1:80"); +TEST(RGWEndpointTest, set_query) { + RGWEndpoint ep("http://example.com:8080/path"); + ep.set_query("key=val&k2=v2"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/path?key=val&k2=v2"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080/path"); +} - RGWEndpoint ep2 = ep.with_url("http://modified.example.com"); +TEST(RGWEndpointTest, set_query_with_leading_question_mark) { + RGWEndpoint ep("http://example.com:8080/path"); + ep.set_query("?key=val"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/path?key=val"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080/path"); +} - // Original unchanged - EXPECT_EQ(ep.get_url(), "http://original.example.com"); - EXPECT_EQ(ep.get_original_url(), "http://original.example.com"); +TEST(RGWEndpointTest, set_host) { + RGWEndpoint ep("http://example.com:8080"); + ep.set_host("bucket.example.com"); + EXPECT_EQ(ep.get_url(), "http://bucket.example.com:8080"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); +} - // Copy has new url but preserves original_url and connect_to - EXPECT_EQ(ep2.get_url(), "http://modified.example.com"); - EXPECT_EQ(ep2.get_original_url(), "http://original.example.com"); - EXPECT_EQ(ep2.get_connect_to(), "original.example.com:80:192.168.1.1:80"); +TEST(RGWEndpointTest, add_trailing_slash) { + RGWEndpoint ep("http://example.com:8080"); + ep.add_trailing_slash(); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); + + // Should not add another slash + ep.add_trailing_slash(); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); +} + +TEST(RGWEndpointTest, set_query_empty_is_noop) { + RGWEndpoint ep("http://example.com:8080/path"); + ep.set_query(""); + // Empty query should not add a '?' to the URL + EXPECT_EQ(ep.get_url(), "http://example.com:8080/path"); +} + +TEST(RGWEndpointTest, set_path_without_leading_slash) { + RGWEndpoint ep("http://example.com:8080"); + ep.set_path("admin/bucket"); + // boost::urls should auto-add leading '/' for URLs with authority + EXPECT_EQ(ep.get_url(), "http://example.com:8080/admin/bucket"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); +} + +TEST(RGWEndpointTest, set_path_then_set_query) { + // This mirrors the forward_request() usage pattern + RGWEndpoint ep("http://example.com:8080"); + ep.set_path("/admin/bucket"); + ep.set_query("?max-entries=100&stats=true"); + EXPECT_EQ(ep.get_url(), "http://example.com:8080/admin/bucket?max-entries=100&stats=true"); + EXPECT_EQ(ep.get_endpoint_url_lookup_id(), "http://example.com:8080"); } TEST(RGWEndpointTest, set_connect_to) { @@ -76,39 +115,22 @@ TEST(RGWEndpointTest, set_connect_to) { EXPECT_EQ(ep.get_connect_to(), "example.com:8080:192.168.1.1:8080"); } -TEST(RGWEndpointTest, add_trailing_slash) { - RGWEndpoint ep("http://example.com:8080"); - ep.add_trailing_slash(); - EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); - - // Should not add another slash - ep.add_trailing_slash(); - EXPECT_EQ(ep.get_url(), "http://example.com:8080/"); -} - -TEST(RGWEndpointTest, append_to_url) { - RGWEndpoint ep("http://example.com:8080"); - ep.append_to_url("/path/to/resource"); - EXPECT_EQ(ep.get_url(), "http://example.com:8080/path/to/resource"); -} - // Tests for operator<< TEST(RGWEndpointTest, ostream_operator_url_only) { RGWEndpoint ep("http://example.com:8080"); std::ostringstream oss; oss << ep; - EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080"); + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080 endpoint_url_lookup_id=http://example.com:8080 connect_to="); } -TEST(RGWEndpointTest, ostream_operator_with_different_original_url) { - RGWEndpoint ep; - ep.set_url("http://original.example.com:8080"); - ep.set_url("http://modified.example.com:8080"); // original_url stays the same +TEST(RGWEndpointTest, ostream_operator_with_modified_url) { + RGWEndpoint ep("http://example.com:8080"); + ep.set_path("/modified"); std::ostringstream oss; oss << ep; - EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://modified.example.com:8080 original_url=http://original.example.com:8080"); + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080/modified endpoint_url_lookup_id=http://example.com:8080 connect_to="); } TEST(RGWEndpointTest, ostream_operator_with_connect_to) { @@ -117,7 +139,7 @@ TEST(RGWEndpointTest, ostream_operator_with_connect_to) { std::ostringstream oss; oss << ep; - EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080 connect_to=example.com:8080:192.168.1.1:8080"); + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://example.com:8080 endpoint_url_lookup_id=http://example.com:8080 connect_to=example.com:8080:192.168.1.1:8080"); } TEST(RGWEndpointTest, ostream_operator_full) { @@ -128,5 +150,5 @@ TEST(RGWEndpointTest, ostream_operator_full) { std::ostringstream oss; oss << ep; - EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://192.168.1.1:8080 original_url=http://original.example.com:8080 connect_to=original.example.com:8080:192.168.1.1:8080"); + EXPECT_EQ(oss.str(), "RGWEndpoint: url=http://192.168.1.1:8080 endpoint_url_lookup_id=http://original.example.com:8080 connect_to=original.example.com:8080:192.168.1.1:8080"); } From 28f2a5e22afaae6794a1f6fe766b61ad0afe8024 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 6 May 2026 19:17:55 +0000 Subject: [PATCH 098/596] rgw/multisite: fix endpoint unreachable detection in RGWRESTConn sync paths The checks that decide whether to call set_endpoint_unconnectable() were comparing against -EIO, but the actual error codes returned on connection failure changed after commit 37352a9074 ("rgw: change rgw_http_error_to_errno default to -ERR_INTERNAL_ERROR"). - complete_request() -> wait() returns req_data->ret which is set to rgw_http_error_to_errno(0) = -ERR_INTERNAL_ERROR when http_status is 0 (TCP connect failed). Fix the six call sites to check -ERR_INTERNAL_ERROR. - forward_request() returns tl::unexpected(-ERR_SERVICE_UNAVAILABLE) when http_status == 0 (no HTTP response received at all). Fix the two forward/forward_iam conditionals to check -ERR_SERVICE_UNAVAILABLE. Without this fix, connection failures are never detected in the sync paths, so set_endpoint_unconnectable() is never called and the IP failover / retry logic is effectively dead. The .h coroutine paths were already fixed by dbb409e21b9 ("rgw: fix endpoint detection in RGWRESTConn") but that commit missed all .cc sync paths. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index 46f6685c6fd..ce3425f6e8c 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -329,7 +329,7 @@ auto RGWRESTConn::forward(const DoutPrefixProvider *dpp, const rgw_owner& uid, auto result = req.forward_request(dpp, key, info, max_response, inbl, outbl, y); if (result) { return result; - } else if (result.error() != -EIO) { + } else if (result.error() != -ERR_SERVICE_UNAVAILABLE) { return result; } set_endpoint_unconnectable(endpoint); @@ -360,7 +360,7 @@ auto RGWRESTConn::forward_iam(const DoutPrefixProvider *dpp, const req_info& inf auto result = req.forward_request(dpp, key, info, max_response, inbl, outbl, y, service); if (result) { return result; - } else if (result.error() != -EIO) { + } else if (result.error() != -ERR_SERVICE_UNAVAILABLE) { return result; } set_endpoint_unconnectable(endpoint); @@ -415,7 +415,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, real_time *mtime, optional_yield y) { int ret = req->complete_request(dpp, y, &etag, mtime); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; set_endpoint_unconnectable(req->get_endpoint()); } @@ -578,7 +578,7 @@ int RGWRESTConn::complete_request(const DoutPrefixProvider* dpp, optional_yield y) { int ret = req->complete_request(dpp, y, etag, mtime, psize, pattrs, pheaders); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { ldout(cct, 5) << __func__ << ": complete_request() returned ret=" << ret << dendl; set_endpoint_unconnectable(req->get_endpoint()); } @@ -629,7 +629,7 @@ int RGWRESTConn::get_resource(const DoutPrefixProvider *dpp, } ret = req.complete_request(dpp, y); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to get resource. retries=" << tries << dendl; @@ -683,7 +683,7 @@ int RGWRESTConn::send_resource(const DoutPrefixProvider *dpp, const std::string& } ret = req.complete_request(dpp, y); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { set_endpoint_unconnectable(endpoint); if (tries < NUM_ENPOINT_IOERROR_RETRIES - 1) { ldpp_dout(dpp, 20) << __func__ << "(): failed to send resource. retries=" << tries << dendl; @@ -742,7 +742,7 @@ int RGWRESTReadResource::read(const DoutPrefixProvider *dpp, optional_yield y) } ret = req.complete_request(dpp, y); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { conn->set_endpoint_unconnectable(req.get_endpoint()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } @@ -809,7 +809,7 @@ int RGWRESTSendResource::send(const DoutPrefixProvider *dpp, bufferlist& outbl, } ret = req.complete_request(dpp, y); - if (ret == -EIO) { + if (ret == -ERR_INTERNAL_ERROR) { conn->set_endpoint_unconnectable(req.get_endpoint()); ldpp_dout(dpp, 20) << __func__ << ": complete_request() returned ret=" << ret << dendl; } From 7d62871ba48488494f09f650e6cae99fbe704b59 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 3 Jun 2026 16:37:15 +0000 Subject: [PATCH 099/596] rgw/rest: add TODO for concurrent endpoint DNS resolution Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_rest_conn.cc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/rgw/rgw_rest_conn.cc b/src/rgw/rgw_rest_conn.cc index ce3425f6e8c..6beb262e84a 100644 --- a/src/rgw/rgw_rest_conn.cc +++ b/src/rgw/rgw_rest_conn.cc @@ -13,6 +13,11 @@ using namespace std; void RGWRESTConn::resolve_endpoints() { + // TODO: resolve endpoints concurrently using async_resolve on a shared io_context. + // In practice zones have a single endpoint or at most a handful of individual + // RGW instances, and resolution runs once at process startup, so sequential + // cost is negligible. A natural time to address this would be when adding + // periodic DNS re-resolution. for (auto& res_ep : resolved_endpoints) { const std::string& ep_url = res_ep.url; // parse URL From 7114fd10f67265410f0d8a9d96977ce94159959c Mon Sep 17 00:00:00 2001 From: Vallari Agrawal Date: Fri, 5 Jun 2026 10:44:51 +0530 Subject: [PATCH 100/596] monitoring: fix NVMeoFMultipleNamespacesOfRBDImage Do not trigger alert NVMeoFMultipleNamespacesOfRBDImage for same pool/image name used in multiple nvmeof namespaces, if they are in different rados namespaces (rados_namespace_name) These are valid repeation of pool/image name: - mypool/rados_ns1/myimage1 - mypool/rados_ns2/myimage1 - mypool/myimage1 (default rados namespace) Fixes: https://tracker.ceph.com/issues/77128 Signed-off-by: Vallari Agrawal --- .../ceph-mixin/prometheus_alerts.libsonnet | 4 +-- monitoring/ceph-mixin/prometheus_alerts.yml | 5 ++- .../ceph-mixin/tests_alerts/test_alerts.yml | 34 ++++++++++++++----- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/monitoring/ceph-mixin/prometheus_alerts.libsonnet b/monitoring/ceph-mixin/prometheus_alerts.libsonnet index 9c1c3db4375..6173a9dd781 100644 --- a/monitoring/ceph-mixin/prometheus_alerts.libsonnet +++ b/monitoring/ceph-mixin/prometheus_alerts.libsonnet @@ -858,10 +858,10 @@ { alert: 'NVMeoFMultipleNamespacesOfRBDImage', 'for': '1m', - expr: 'count by(pool_name, rbd_name) (count by(bdev_name, pool_name, rbd_name) (ceph_nvmeof_bdev_metadata and on (bdev_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1', + expr: 'count by(pool_name, rbd_name, rados_namespace_name) (count by(bdev_name, pool_name, rbd_name, rados_namespace_name) (ceph_nvmeof_bdev_metadata * on (bdev_name, instance, cluster) group_left(rados_namespace_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1', labels: { severity: 'warning', type: 'ceph_default' }, annotations: { - summary: 'RBD image {{ $labels.pool_name }}/{{ $labels.rbd_name }} cannot be reused for multiple NVMeoF namespace ', + summary: 'RBD image {{ $labels.pool_name }}/{{ if $labels.rados_namespace_name }}{{ $labels.rados_namespace_name }}/{{ end }}{{ $labels.rbd_name }} cannot be reused for multiple NVMeoF namespaces', description: 'Each NVMeoF namespace must have a unique RBD pool and image, across all different gateway groups.', }, }, diff --git a/monitoring/ceph-mixin/prometheus_alerts.yml b/monitoring/ceph-mixin/prometheus_alerts.yml index 8a6c411a2b6..f2aa4defabd 100644 --- a/monitoring/ceph-mixin/prometheus_alerts.yml +++ b/monitoring/ceph-mixin/prometheus_alerts.yml @@ -804,8 +804,8 @@ groups: - alert: "NVMeoFMultipleNamespacesOfRBDImage" annotations: description: "Each NVMeoF namespace must have a unique RBD pool and image, across all different gateway groups." - summary: "RBD image {{ $labels.pool_name }}/{{ $labels.rbd_name }} cannot be reused for multiple NVMeoF namespace " - expr: "count by(pool_name, rbd_name) (count by(bdev_name, pool_name, rbd_name) (ceph_nvmeof_bdev_metadata and on (bdev_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1" + summary: "RBD image {{ $labels.pool_name }}/{{ if $labels.rados_namespace_name }}{{ $labels.rados_namespace_name }}/{{ end }}{{ $labels.rbd_name }} cannot be reused for multiple NVMeoF namespaces" + expr: "count by(pool_name, rbd_name, rados_namespace_name) (count by(bdev_name, pool_name, rbd_name, rados_namespace_name) (ceph_nvmeof_bdev_metadata * on (bdev_name, instance, cluster) group_left(rados_namespace_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1" for: "1m" labels: severity: "warning" @@ -995,4 +995,3 @@ groups: oid: "1.3.6.1.4.1.50495.1.2.1.15.2" severity: "warning" type: "ceph_default" - diff --git a/monitoring/ceph-mixin/tests_alerts/test_alerts.yml b/monitoring/ceph-mixin/tests_alerts/test_alerts.yml index 5578544c934..6a4c2cd2076 100644 --- a/monitoring/ceph-mixin/tests_alerts/test_alerts.yml +++ b/monitoring/ceph-mixin/tests_alerts/test_alerts.yml @@ -2278,19 +2278,27 @@ tests: # NVMeoFMultipleNamespacesOfRBDImage - interval: 1m input_series: - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev1", instance="ceph-nvme-vm1", pool_name="mypool", rbd_name="myimage1"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev1", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage1"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev1", instance="ceph-nvme-vm2", pool_name="mypool", rbd_name="myimage1"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev1", instance="ceph-nvme-vm2", cluster="mycluster", pool_name="mypool", rbd_name="myimage1"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev2", instance="ceph-nvme-vm1", pool_name="mypool", rbd_name="myimage2"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev2", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage2"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev2", instance="ceph-nvme-vm2", pool_name="mypool", rbd_name="myimage2"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev2", instance="ceph-nvme-vm2", cluster="mycluster", pool_name="mypool", rbd_name="myimage2"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev3", instance="ceph-nvme-vm1", pool_name="mypool", rbd_name="myimage1"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev3", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage1"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev3", instance="ceph-nvme-vm2", pool_name="mypool", rbd_name="myimage1"}' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev3", instance="ceph-nvme-vm2", cluster="mycluster", pool_name="mypool", rbd_name="myimage1"}' values: '1x10' - - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev4", instance="ceph-nvme-vm1", pool_name="mypool", rbd_name="myimage1"}' # bdev with no ns + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev4", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage1"}' # bdev with no ns + values: '1x10' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev5", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage5"}' + values: '1x10' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev5", instance="ceph-nvme-vm2", cluster="mycluster", pool_name="mypool", rbd_name="myimage5"}' + values: '1x10' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev6", instance="ceph-nvme-vm1", cluster="mycluster", pool_name="mypool", rbd_name="myimage5"}' + values: '1x10' + - series: 'ceph_nvmeof_bdev_metadata{bdev_name="bdev6", instance="ceph-nvme-vm2", cluster="mycluster", pool_name="mypool", rbd_name="myimage5"}' values: '1x10' - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn1", nsid="1", bdev_name="bdev1", instance="ceph-nvme-vm1", cluster="mycluster"}' values: '1x10' @@ -2304,8 +2312,16 @@ tests: values: '1x10' - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn2", nsid="1", bdev_name="bdev3", instance="ceph-nvme-vm2", cluster="mycluster"}' values: '1x10' + - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn3", nsid="1", bdev_name="bdev5", instance="ceph-nvme-vm1", cluster="mycluster", rados_namespace_name="rados1"}' + values: '1x10' + - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn3", nsid="1", bdev_name="bdev5", instance="ceph-nvme-vm2", cluster="mycluster", rados_namespace_name="rados1"}' + values: '1x10' + - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn3", nsid="2", bdev_name="bdev6", instance="ceph-nvme-vm1", cluster="mycluster", rados_namespace_name="rados2"}' + values: '1x10' + - series: 'ceph_nvmeof_subsystem_namespace_metadata{nqn="nqn3", nsid="2", bdev_name="bdev6", instance="ceph-nvme-vm2", cluster="mycluster", rados_namespace_name="rados2"}' + values: '1x10' promql_expr_test: - - expr: count by(pool_name, rbd_name) (count by(bdev_name, pool_name, rbd_name) (ceph_nvmeof_bdev_metadata and on (bdev_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1 + - expr: count by(pool_name, rbd_name, rados_namespace_name) (count by(bdev_name, pool_name, rbd_name, rados_namespace_name) (ceph_nvmeof_bdev_metadata * on (bdev_name, instance, cluster) group_left(rados_namespace_name) ceph_nvmeof_subsystem_namespace_metadata)) > 1 eval_time: 1m exp_samples: - labels: '{pool_name="mypool", rbd_name="myimage1"}' @@ -2320,7 +2336,7 @@ tests: severity: warning type: ceph_default exp_annotations: - summary: "RBD image mypool/myimage1 cannot be reused for multiple NVMeoF namespace " + summary: "RBD image mypool/myimage1 cannot be reused for multiple NVMeoF namespaces" description: "Each NVMeoF namespace must have a unique RBD pool and image, across all different gateway groups." # NVMeoFTooManyGateways From d486328b71c05524aaeef7b9b92dfc650dc9e949 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Fri, 5 Jun 2026 10:02:34 +0200 Subject: [PATCH 101/596] qa/cephadm: fix test_repos.sh for jammy nodes The test uses add-repo --release 17.2.6 to verify version-string repo handling, but debian-17.2.6 only has focal and bullseye suites and jammy packages weren't built until 17.2.7. This causes apt-get update to fail with a 404 on ubuntu_22.04 nodes. see: https://download.ceph.com/debian-17.2.6/dists/ This fix bumps the version to 17.2.7 which includes a jammy suite. Fixes: https://tracker.ceph.com/issues/77130 Signed-off-by: Redouane Kachach --- qa/workunits/cephadm/test_repos.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qa/workunits/cephadm/test_repos.sh b/qa/workunits/cephadm/test_repos.sh index 5c17e51060e..2a729923261 100755 --- a/qa/workunits/cephadm/test_repos.sh +++ b/qa/workunits/cephadm/test_repos.sh @@ -38,7 +38,7 @@ sudo $CEPHADM -v add-repo --dev main test_install_uninstall sudo $CEPHADM -v rm-repo -sudo $CEPHADM -v add-repo --release 17.2.6 +sudo $CEPHADM -v add-repo --release 17.2.7 test_install_uninstall sudo $CEPHADM -v rm-repo From c8ffac91f0f7dd6c0124c3a3ea0daa4913f54d35 Mon Sep 17 00:00:00 2001 From: Kobi Ginon Date: Mon, 25 May 2026 15:38:34 +0300 Subject: [PATCH 102/596] cephadm: set Grafana http_addr to 0.0.0.0 when unset Grafana 11.1+ rejects non-literal http_addr values (e.g. localhost) in grafana-apiserver. Use 0.0.0.0 by default; stop bracket-wrapping IPv6 addresses in http_addr. Fixes: https://tracker.ceph.com/issues/75365 Signed-off-by: Kobi Ginon --- src/pybind/mgr/cephadm/services/monitoring.py | 12 ++++-- .../cephadm/tests/services/test_monitoring.py | 38 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/pybind/mgr/cephadm/services/monitoring.py b/src/pybind/mgr/cephadm/services/monitoring.py index 86f0aa0a8e8..e3fd676e22c 100644 --- a/src/pybind/mgr/cephadm/services/monitoring.py +++ b/src/pybind/mgr/cephadm/services/monitoring.py @@ -2,7 +2,6 @@ import errno import logging import os from typing import List, Any, Tuple, Dict, Optional, cast, TYPE_CHECKING -import ipaddress import time import requests @@ -74,8 +73,15 @@ class GrafanaService(CephadmService): if ip_to_bind_to: daemon_spec.port_ips = {str(grafana_port): ip_to_bind_to} grafana_ip = ip_to_bind_to - if ipaddress.ip_network(grafana_ip).version == 6: - grafana_ip = f"[{grafana_ip}]" + + if not grafana_ip: + # Grafana 11.1+ validates http_addr with net.ParseIP; hostnames such as + # localhost fail in grafana-apiserver. Use a literal address (bind all IPv4). + # Check if the primary manager or orchestrator is configured for IPv6 + if self.mgr.get_mgr_ip().startswith('::') or ':' in self.mgr.get_mgr_ip(): + grafana_ip = '::' + else: + grafana_ip = '0.0.0.0' domain = self.mgr.get_fqdn(daemon_spec.host) mgmt_gw_ips = [] diff --git a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py index 57a9d8a3da1..d582b07863c 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py +++ b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py @@ -1329,7 +1329,7 @@ class TestMonitoring: cert_file = /etc/grafana/certs/cert_file cert_key = /etc/grafana/certs/cert_key http_port = 3000 - http_addr = + http_addr = :: root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ serve_from_sub_path = true [snapshots] @@ -1490,7 +1490,7 @@ class TestMonitoring: cert_file = /etc/grafana/certs/cert_file cert_key = /etc/grafana/certs/cert_key http_port = 3000 - http_addr = + http_addr = :: root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ serve_from_sub_path = true [snapshots] @@ -1590,7 +1590,7 @@ class TestMonitoring: cert_file = /etc/grafana/certs/cert_file cert_key = /etc/grafana/certs/cert_key http_port = 3000 - http_addr = + http_addr = :: [snapshots] external_enabled = false [security] @@ -1681,6 +1681,7 @@ class TestMonitoring: ) @patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}')) + @patch("cephadm.module.CephadmOrchestrator.get_mgr_ip", lambda _: '192.0.2.1') def test_grafana_initial_admin_pw(self, cephadm_module: CephadmOrchestrator): with with_host(cephadm_module, 'test'): with with_service(cephadm_module, ServiceSpec('mgr')) as _, \ @@ -1705,7 +1706,7 @@ class TestMonitoring: ' cert_file = /etc/grafana/certs/cert_file\n' ' cert_key = /etc/grafana/certs/cert_key\n' ' http_port = 3000\n' - ' http_addr = \n' + ' http_addr = 0.0.0.0\n' '[snapshots]\n' ' external_enabled = false\n' '[security]\n' @@ -1750,6 +1751,33 @@ class TestMonitoring: }}, ['certificate_source: cephadm-signed', 'secure_monitoring_stack:False']) @patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}')) + @patch("cephadm.module.CephadmOrchestrator.get_mgr_ip", lambda _: '192.0.2.1') + def test_grafana_http_addr_binds_all_by_default_ipv4(self, cephadm_module: CephadmOrchestrator): + grafana_svc = service_registry.get_service('grafana') + with with_host(cephadm_module, 'test'): + with with_service(cephadm_module, GrafanaSpec()): + ini = grafana_svc.generate_grafana_ini( + CephadmDaemonDeploySpec('test', 'daemon', 'grafana'), + mgmt_gw_enabled=False, + oauth2_enabled=False, + ) + assert 'http_addr = 0.0.0.0' in ini + + @patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}')) + @patch("cephadm.module.CephadmOrchestrator.get_mgr_ip", lambda _: '1::4') + def test_grafana_http_addr_binds_all_by_default_ipv6(self, cephadm_module: CephadmOrchestrator): + grafana_svc = service_registry.get_service('grafana') + with with_host(cephadm_module, 'test'): + with with_service(cephadm_module, GrafanaSpec()): + ini = grafana_svc.generate_grafana_ini( + CephadmDaemonDeploySpec('test', 'daemon', 'grafana'), + mgmt_gw_enabled=False, + oauth2_enabled=False, + ) + assert 'http_addr = ::' in ini + + @patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}')) + @patch("cephadm.module.CephadmOrchestrator.get_mgr_ip", lambda _: '192.0.2.1') def test_grafana_no_anon_access(self, cephadm_module: CephadmOrchestrator): # with anonymous_access set to False, expecting the [auth.anonymous] section # to not be present in the grafana config. Note that we require an initial_admin_password @@ -1774,7 +1802,7 @@ class TestMonitoring: ' cert_file = /etc/grafana/certs/cert_file\n' ' cert_key = /etc/grafana/certs/cert_key\n' ' http_port = 3000\n' - ' http_addr = \n' + ' http_addr = 0.0.0.0\n' '[snapshots]\n' ' external_enabled = false\n' '[security]\n' From cc1dcecee0892d7552ead76e3fb534e95df15fc5 Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Sun, 31 May 2026 16:36:13 +0330 Subject: [PATCH 103/596] rgw: match CORS rules like Amazon S3 Select the first CORSRule whose AllowedOrigin, AllowedMethod, and (for preflight) AllowedHeader all match the request. Previously host_name_rule matched origin only, so a second rule with the same origin but different methods was never used. Add RGWCORSRule::matches() and RGWCORSConfiguration::match_rule(), wire generate_cors_headers and RGWOptionsCORS to them (including global CORS), and add unittest_rgw_cors. Signed-off-by: ramin.najarbashi Co-authored-by: Cursor --- src/rgw/rgw_cors.cc | 51 ++++++++++++++++++++++++ src/rgw/rgw_cors.h | 8 ++++ src/rgw/rgw_op.cc | 63 ++++++++++++------------------ src/test/rgw/CMakeLists.txt | 6 +++ src/test/rgw/test_rgw_cors.cc | 73 +++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 39 deletions(-) create mode 100644 src/test/rgw/test_rgw_cors.cc diff --git a/src/rgw/rgw_cors.cc b/src/rgw/rgw_cors.cc index e8bbdabe985..230a04664b8 100644 --- a/src/rgw/rgw_cors.cc +++ b/src/rgw/rgw_cors.cc @@ -17,6 +17,7 @@ #include #include +#include #include @@ -238,6 +239,56 @@ void RGWCORSRule::format_exp_headers(string& s) { } } +bool RGWCORSRule::matches_method(const char *req_meth) +{ + if (!req_meth || !*req_meth) { + return true; + } + const uint8_t flags = get_multi_cors_method_flags(req_meth); + return flags != 0 && (allowed_methods & flags); +} + +bool RGWCORSRule::matches_preflight_headers(const char *req_hdrs) +{ + if (!req_hdrs || !*req_hdrs) { + return true; + } + vector hdrs; + get_str_vec(req_hdrs, hdrs); + for (const auto& hdr : hdrs) { + if (!is_header_allowed(hdr.c_str(), hdr.length())) { + return false; + } + } + return true; +} + +bool RGWCORSRule::matches(const char *origin, + const char *req_meth, + const char *req_hdrs) +{ + if (!origin || !*origin) { + return false; + } + return is_origin_present(origin) + && matches_method(req_meth) + && matches_preflight_headers(req_hdrs); +} + +RGWCORSRule * RGWCORSConfiguration::match_rule(const char *origin, + const char *req_meth, + const char *req_hdrs) +{ + for (list::iterator it_r = rules.begin(); + it_r != rules.end(); ++it_r) { + RGWCORSRule& r = (*it_r); + if (r.matches(origin, req_meth, req_hdrs)) { + return &r; + } + } + return NULL; +} + RGWCORSRule * RGWCORSConfiguration::host_name_rule(const char *origin) { for(list::iterator it_r = rules.begin(); it_r != rules.end(); ++it_r) { diff --git a/src/rgw/rgw_cors.h b/src/rgw/rgw_cors.h index fe4f5ecdbd9..c500fab29cb 100644 --- a/src/rgw/rgw_cors.h +++ b/src/rgw/rgw_cors.h @@ -91,6 +91,11 @@ public: void dump_origins(); void dump(Formatter *f) const; bool is_header_allowed(const char *hdr, size_t len); + bool matches_method(const char *req_meth); + bool matches_preflight_headers(const char *req_hdrs); + bool matches(const char *origin, + const char *req_meth, + const char *req_hdrs); }; WRITE_CLASS_ENCODER(RGWCORSRule) @@ -121,6 +126,9 @@ class RGWCORSConfiguration } void get_origins_list(const char *origin, std::list& origins); RGWCORSRule * host_name_rule(const char *origin); + RGWCORSRule * match_rule(const char *origin, + const char *req_meth, + const char *req_hdrs); void erase_host_name_rule(std::string& origin); void dump(); void stack_rule(RGWCORSRule& r) { diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index 2a015ab16c3..494d089a733 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -2000,8 +2000,14 @@ bool RGWOp::generate_cors_headers(string& origin, string& method, string& header return false; } - /* CORS 6.2.2. */ - RGWCORSRule *rule = bucket_cors.host_name_rule(orig); + /* CORS 6.2.2–6.2.5: first rule matching origin, method, and preflight headers. */ + const char *req_meth = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_METHOD"); + if (!req_meth) { + req_meth = s->info.method; + } + const char *req_hdrs = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_HEADERS"); + + RGWCORSRule *rule = bucket_cors.match_rule(orig, req_meth, req_hdrs); auto is_allowed_to_generate_rule_cors_header = [this, &origin, &method, &headers, &exp_headers, &max_age] (RGWCORSRule *rule) { if (!rule) return false; @@ -2019,24 +2025,20 @@ bool RGWOp::generate_cors_headers(string& origin, string& method, string& header origin = "*"; /* CORS 6.2.3. */ - const char *req_meth = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_METHOD"); - if (!req_meth) { - req_meth = s->info.method; + const char *req_meth_inner = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_METHOD"); + if (!req_meth_inner) { + req_meth_inner = s->info.method; } - if (req_meth) { - method = req_meth; - /* CORS 6.2.5. */ - if (!validate_cors_rule_method(this, rule, req_meth)) { - return false; - } + if (req_meth_inner) { + method = req_meth_inner; } /* CORS 6.2.4. */ - const char *req_hdrs = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_HEADERS"); + const char *req_hdrs_inner = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_HEADERS"); /* CORS 6.2.6. */ - get_cors_response_headers(this, rule, req_hdrs, headers, exp_headers, max_age); + get_cors_response_headers(this, rule, req_hdrs_inner, headers, exp_headers, max_age); return true; }; @@ -2045,7 +2047,9 @@ bool RGWOp::generate_cors_headers(string& origin, string& method, string& header return true; } - if (optional_global_cors.has_value() && is_allowed_to_generate_rule_cors_header(&(*optional_global_cors))) { + if (optional_global_cors.has_value() && + optional_global_cors->matches(orig, req_meth, req_hdrs) && + is_allowed_to_generate_rule_cors_header(&(*optional_global_cors))) { return true; } @@ -7253,17 +7257,10 @@ void RGWOptionsCORS::get_response_params(string& hdrs, string& exp_hdrs, unsigne } int RGWOptionsCORS::validate_cors_request(RGWCORSConfiguration *cc) { - rule = cc->host_name_rule(origin); + rule = cc->match_rule(origin, req_meth, req_hdrs); if (!rule) { - ldpp_dout(this, 10) << "There is no cors rule present for " << origin << dendl; - return -ENOENT; - } - - if (!validate_cors_rule_method(this, rule, req_meth)) { - return -ENOENT; - } - - if (!validate_cors_rule_header(this, rule, req_hdrs)) { + ldpp_dout(this, 10) << "no CORS rule matching origin=" << origin + << " method=" << req_meth << dendl; return -ENOENT; } @@ -7273,21 +7270,9 @@ int RGWOptionsCORS::validate_cors_request(RGWCORSConfiguration *cc) { int RGWOptionsCORS::validate_global_cors_request(RGWCORSRule *global_cors_rule) { ldpp_dout(this, 20) << "Validating request with global CORS" << dendl; rule = global_cors_rule; - if (!rule) { - ldpp_dout(this, 10) << "There is no global cors rule present" << dendl; - return -ENOENT; - } - - if (!rule->is_origin_present(origin)) { - ldpp_dout(this, 10) << "There is no cors rule present for " << origin << dendl; - return -ENOENT; - } - - if (!validate_cors_rule_method(this, rule, req_meth)) { - return -ENOENT; - } - - if (!validate_cors_rule_header(this, rule, req_hdrs)) { + if (!rule || !rule->matches(origin, req_meth, req_hdrs)) { + ldpp_dout(this, 10) << "no global CORS rule matching origin=" << origin + << " method=" << req_meth << dendl; return -ENOENT; } diff --git a/src/test/rgw/CMakeLists.txt b/src/test/rgw/CMakeLists.txt index c587b5834dc..a61f23bd46a 100644 --- a/src/test/rgw/CMakeLists.txt +++ b/src/test/rgw/CMakeLists.txt @@ -102,6 +102,12 @@ add_executable(unittest_rgw_bencode test_rgw_bencode.cc) add_ceph_unittest(unittest_rgw_bencode) target_link_libraries(unittest_rgw_bencode ${rgw_libs}) +# unittest_rgw_cors +add_executable(unittest_rgw_cors test_rgw_cors.cc) +add_ceph_unittest(unittest_rgw_cors) +target_link_libraries(unittest_rgw_cors ${rgw_libs}) + + # unittest_rgw_bucket_sync_cache add_executable(unittest_rgw_bucket_sync_cache test_rgw_bucket_sync_cache.cc) add_ceph_unittest(unittest_rgw_bucket_sync_cache) diff --git a/src/test/rgw/test_rgw_cors.cc b/src/test/rgw/test_rgw_cors.cc new file mode 100644 index 00000000000..b3a1afa2751 --- /dev/null +++ b/src/test/rgw/test_rgw_cors.cc @@ -0,0 +1,73 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab + +#include "gtest/gtest.h" + +#include "rgw_cors.h" + +using namespace std; + +static RGWCORSRule make_rule(const char *origin, uint8_t methods, + const char *allowed_hdr = nullptr) +{ + set origins; + origins.insert(origin); + set hdrs; + if (allowed_hdr) { + hdrs.insert(allowed_hdr); + } + list expose; + return RGWCORSRule(origins, hdrs, expose, methods, 3000); +} + +TEST(RGWCORS, MatchRuleSameOriginDifferentMethods) +{ + RGWCORSConfiguration cfg; + cfg.get_rules().push_back(make_rule("https://app.example", RGW_CORS_GET)); + cfg.get_rules().push_back(make_rule("https://app.example", RGW_CORS_PUT)); + + auto it_get = cfg.get_rules().begin(); + auto it_put = std::next(it_get); + + EXPECT_EQ(cfg.match_rule("https://app.example", "GET", nullptr), &(*it_get)); + EXPECT_EQ(cfg.match_rule("https://app.example", "PUT", nullptr), &(*it_put)); + EXPECT_EQ(cfg.match_rule("https://app.example", "DELETE", nullptr), nullptr); +} + +TEST(RGWCORS, MatchRuleSkipsOriginOnlyMatch) +{ + RGWCORSConfiguration cfg; + cfg.get_rules().push_back(make_rule("https://app.example", RGW_CORS_GET)); + cfg.get_rules().push_back(make_rule("https://app.example", RGW_CORS_PUT)); + + EXPECT_NE(nullptr, cfg.match_rule("https://app.example", "PUT", nullptr)); +} + +TEST(RGWCORS, MatchRulePreflightHeaders) +{ + RGWCORSConfiguration cfg; + cfg.get_rules().push_back( + make_rule("https://app.example", RGW_CORS_GET, "content-type")); + cfg.get_rules().push_back( + make_rule("https://app.example", RGW_CORS_GET, "*")); + + auto it_strict = cfg.get_rules().begin(); + auto it_wild = std::next(it_strict); + + EXPECT_EQ(cfg.match_rule("https://app.example", "GET", + "content-type"), + &(*it_strict)); + EXPECT_EQ(cfg.match_rule("https://app.example", "GET", + "authorization"), + &(*it_wild)); +} + +TEST(RGWCORS, HostNameRuleStillOriginOnly) +{ + RGWCORSConfiguration cfg; + cfg.get_rules().push_back(make_rule("https://app.example", RGW_CORS_GET)); + cfg.get_rules().push_back(make_rule("https://other.example", RGW_CORS_PUT)); + + auto it_first = cfg.get_rules().begin(); + EXPECT_EQ(cfg.host_name_rule("https://app.example"), &(*it_first)); +} From bbec6957902f7acaa9e657d398b5dbb453b9fe6b Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Sat, 6 Jun 2026 16:26:57 +0330 Subject: [PATCH 104/596] reject null or empty method in matches_method Signed-off-by: ramin.najarbashi --- src/rgw/rgw_cors.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_cors.cc b/src/rgw/rgw_cors.cc index 230a04664b8..266dea47681 100644 --- a/src/rgw/rgw_cors.cc +++ b/src/rgw/rgw_cors.cc @@ -242,7 +242,8 @@ void RGWCORSRule::format_exp_headers(string& s) { bool RGWCORSRule::matches_method(const char *req_meth) { if (!req_meth || !*req_meth) { - return true; + dout(5) << "matches_method: req_meth is null or empty" << dendl; + return false; } const uint8_t flags = get_multi_cors_method_flags(req_meth); return flags != 0 && (allowed_methods & flags); From b5624b63f2245058acee9045386bb9f81e06fae2 Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Sat, 6 Jun 2026 16:46:53 +0330 Subject: [PATCH 105/596] fix: some issue based on CORS spec 6.2.4 Signed-off-by: ramin.najarbashi --- src/rgw/rgw_cors.cc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/rgw/rgw_cors.cc b/src/rgw/rgw_cors.cc index 266dea47681..c1d20a6e2cf 100644 --- a/src/rgw/rgw_cors.cc +++ b/src/rgw/rgw_cors.cc @@ -245,6 +245,10 @@ bool RGWCORSRule::matches_method(const char *req_meth) dout(5) << "matches_method: req_meth is null or empty" << dendl; return false; } + if (allowed_methods == RGW_CORS_ALL) { + dout(10) << "matches_method: AllowedMethod * allows " << req_meth << dendl; + return true; + } const uint8_t flags = get_multi_cors_method_flags(req_meth); return flags != 0 && (allowed_methods & flags); } @@ -252,6 +256,8 @@ bool RGWCORSRule::matches_method(const char *req_meth) bool RGWCORSRule::matches_preflight_headers(const char *req_hdrs) { if (!req_hdrs || !*req_hdrs) { + dout(20) << "matches_preflight_headers: no Access-Control-Request-Headers, " + << "passing per CORS spec 6.2.4" << dendl; return true; } vector hdrs; From 8b3f907f7c819b16f098142c9242d2abb6fa2829 Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Sat, 6 Jun 2026 17:01:56 +0330 Subject: [PATCH 106/596] rgw: address CORS rule-matching review feedback - matches_method: reject null/empty method; short-circuit RGW_CORS_ALL - matches_preflight_headers: add logging for skip and disallowed headers - generate_cors_headers: capture req_meth/req_hdrs in lambda - remove unused validate_cors_rule_method/header helpers Signed-off-by: ramin.najarbashi On branch fix/rgw-cors-aws-rule-matching Changes to be committed: modified: src/rgw/rgw_cors.cc modified: src/rgw/rgw_op.cc --- src/rgw/rgw_cors.cc | 1 + src/rgw/rgw_op.cc | 48 ++++----------------------------------------- 2 files changed, 5 insertions(+), 44 deletions(-) diff --git a/src/rgw/rgw_cors.cc b/src/rgw/rgw_cors.cc index c1d20a6e2cf..05eb5824921 100644 --- a/src/rgw/rgw_cors.cc +++ b/src/rgw/rgw_cors.cc @@ -264,6 +264,7 @@ bool RGWCORSRule::matches_preflight_headers(const char *req_hdrs) get_str_vec(req_hdrs, hdrs); for (const auto& hdr : hdrs) { if (!is_header_allowed(hdr.c_str(), hdr.length())) { + dout(5) << "Header " << hdr << " is not registered in this rule" << dendl; return false; } } diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index 494d089a733..f553b824270 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -1856,38 +1856,6 @@ int RGWOp::init_quota() return 0; } -static bool validate_cors_rule_method(const DoutPrefixProvider *dpp, RGWCORSRule *rule, const char *req_meth) { - if (!req_meth) { - ldpp_dout(dpp, 5) << "req_meth is null" << dendl; - return false; - } - - uint8_t flags = get_multi_cors_method_flags(req_meth); - - if (rule->get_allowed_methods() & flags) { - ldpp_dout(dpp, 10) << "Method " << req_meth << " is supported" << dendl; - } else { - ldpp_dout(dpp, 5) << "Method " << req_meth << " is not supported" << dendl; - return false; - } - - return true; -} - -static bool validate_cors_rule_header(const DoutPrefixProvider *dpp, RGWCORSRule *rule, const char *req_hdrs) { - if (req_hdrs) { - vector hdrs; - get_str_vec(req_hdrs, hdrs); - for (const auto& hdr : hdrs) { - if (!rule->is_header_allowed(hdr.c_str(), hdr.length())) { - ldpp_dout(dpp, 5) << "Header " << hdr << " is not registered in this rule" << dendl; - return false; - } - } - } - return true; -} - int RGWOp::read_bucket_cors() { bufferlist bl; @@ -2008,7 +1976,7 @@ bool RGWOp::generate_cors_headers(string& origin, string& method, string& header const char *req_hdrs = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_HEADERS"); RGWCORSRule *rule = bucket_cors.match_rule(orig, req_meth, req_hdrs); - auto is_allowed_to_generate_rule_cors_header = [this, &origin, &method, &headers, &exp_headers, &max_age] (RGWCORSRule *rule) { + auto is_allowed_to_generate_rule_cors_header = [this, &origin, &method, &headers, &exp_headers, &max_age, req_meth, req_hdrs] (RGWCORSRule *rule) { if (!rule) return false; @@ -2025,20 +1993,12 @@ bool RGWOp::generate_cors_headers(string& origin, string& method, string& header origin = "*"; /* CORS 6.2.3. */ - const char *req_meth_inner = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_METHOD"); - if (!req_meth_inner) { - req_meth_inner = s->info.method; + if (req_meth) { + method = req_meth; } - if (req_meth_inner) { - method = req_meth_inner; - } - - /* CORS 6.2.4. */ - const char *req_hdrs_inner = s->info.env->get("HTTP_ACCESS_CONTROL_REQUEST_HEADERS"); - /* CORS 6.2.6. */ - get_cors_response_headers(this, rule, req_hdrs_inner, headers, exp_headers, max_age); + get_cors_response_headers(this, rule, req_hdrs, headers, exp_headers, max_age); return true; }; From 8447dfd23cc231cb9232a54d6e57849ffb841fcd Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Sun, 7 Jun 2026 08:28:09 +0000 Subject: [PATCH 107/596] crimson/os/seastore: track per-transaction conflict/replay counts Add a num_replays counter to Transaction, incremented in Cache::mark_transaction_conflicted whenever a transaction is marked conflicted. Only relevant for user-MUTATE path (do_transaction_no_callbacks). Signed-off-by: Matan Breizman --- src/crimson/os/seastore/cache.cc | 3 +++ src/crimson/os/seastore/seastore.cc | 23 +++++++++++++++++++++++ src/crimson/os/seastore/seastore.h | 18 ++++++++++++++++++ src/crimson/os/seastore/transaction.h | 8 ++++++++ 4 files changed, 52 insertions(+) diff --git a/src/crimson/os/seastore/cache.cc b/src/crimson/os/seastore/cache.cc index 8a6b29ca5b9..be90decca48 100644 --- a/src/crimson/os/seastore/cache.cc +++ b/src/crimson/os/seastore/cache.cc @@ -1023,6 +1023,9 @@ void Cache::mark_transaction_conflicted( SUBTRACET(seastore_t, "", t); assert(!t.conflicted); t.conflicted = true; + // count is only *sampled* for the user-MUTATE do_transaction path, + // where the transaction is reused across retries + ++t.num_replays; auto& efforts = get_by_src(stats.invalidated_efforts_by_src, t.get_src()); diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index f66fc9fd964..ffa49c02283 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -203,6 +203,28 @@ void SeaStore::Shard::register_metrics(store_index_t store_index) ); } + stats.conflict_replays.buckets.resize(REPLAY_BUCKETS); + for (std::size_t i = 0; i < REPLAY_BUCKETS; ++i) { + stats.conflict_replays.buckets[i].upper_bound = i; + stats.conflict_replays.buckets[i].count = 0; + } + metrics.add_group( + "seastore", + { + sm::make_histogram( + "conflict_replay_distribution", + [this]() -> seastar::metrics::histogram& { + return stats.conflict_replays; + }, + sm::description("distribution of per-transaction conflict/replay counts " + "before commit, for user transactions submitted via " + "do_transaction (the reused-transaction / " + "with_repeat_trans_intr path); not all MUTATE transactions"), + {sm::label_instance("shard_store_index", std::to_string(store_index))} + ) + } + ); + metrics.add_group( "seastore", { @@ -1711,6 +1733,7 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( ); DEBUGT("done", *ctx.transaction); + add_conflict_replay_sample(ctx.transaction->get_num_replays()); add_latency_sample( op_type_t::DO_TRANSACTION, std::chrono::steady_clock::now() - ctx.begin_timestamp); diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index 54dd67c783f..e2bbc576a43 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -437,9 +437,12 @@ public: 100000 }; + // Buckets for the per-transaction conflict/replay distribution. + static constexpr std::size_t REPLAY_BUCKETS = 16; struct { std::array op_lat; + seastar::metrics::histogram conflict_replays; } stats; seastar::metrics::histogram& get_latency( @@ -467,6 +470,21 @@ public: } } + // Record how many times a just-completed transaction was conflicted/replayed. + // Called only from the do_transaction_no_callbacks() completion path. + void add_conflict_replay_sample(std::size_t num_replays) { + auto& hist = stats.conflict_replays; + if (hist.buckets.empty()) { + // register_metrics() did not run (store inactive); nothing to record. + return; + } + std::size_t idx = num_replays < REPLAY_BUCKETS ? + num_replays : REPLAY_BUCKETS - 1; + ++hist.buckets[idx].count; + ++hist.sample_count; + hist.sample_sum += num_replays; + } + /* * omaptree interfaces */ diff --git a/src/crimson/os/seastore/transaction.h b/src/crimson/os/seastore/transaction.h index a6e2e1c7272..8fa2d011f5e 100644 --- a/src/crimson/os/seastore/transaction.h +++ b/src/crimson/os/seastore/transaction.h @@ -464,6 +464,12 @@ public: return conflicted; } + // Number of times this transaction was conflicted and replayed before + // finally committing. do_transaction_no_callbacks() (user MUTATE writes) + std::size_t get_num_replays() const { + return num_replays; + } + auto &get_handle() { return handle; } @@ -882,6 +888,8 @@ private: bool conflicted = false; + std::size_t num_replays = 0; + bool has_reset = false; OrderingHandle handle; From 89e913d279f8ea7a85460ee77edd05203d52ab04 Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Sun, 7 Jun 2026 09:24:51 +0000 Subject: [PATCH 108/596] seastore: add seastore_do_transaction_stage_lat metric Introduce a new latency breakdown metric for do_transaction, exposing per-stage timing (build, collock_wait, submit, throttler_wait). Signed-off-by: Matan Breizman --- src/crimson/os/seastore/seastore.cc | 41 +++++++++++++++++++++++++++++ src/crimson/os/seastore/seastore.h | 40 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index ffa49c02283..2464032ffae 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -225,6 +225,35 @@ void SeaStore::Shard::register_metrics(store_index_t store_index) } ); + std::pair labels_by_stage[] = { + {txn_stage_t::COLLOCK_WAIT, sm::label_instance("stage", "collock_wait")}, + {txn_stage_t::THROTTLER_WAIT, sm::label_instance("stage", "throttler_wait")}, + {txn_stage_t::BUILD, sm::label_instance("stage", "build")}, + {txn_stage_t::SUBMIT, sm::label_instance("stage", "submit")}, + }; + for (auto& [stage, label] : labels_by_stage) { + auto idx = static_cast(stage); + auto& hist = stats.stage_lat[idx]; + hist.buckets.resize(STAGE_LAT_BUCKETS_US.size()); + for (std::size_t i = 0; i < STAGE_LAT_BUCKETS_US.size(); ++i) { + hist.buckets[i].upper_bound = STAGE_LAT_BUCKETS_US[i]; + hist.buckets[i].count = 0; + } + metrics.add_group( + "seastore", + { + sm::make_histogram( + "do_transaction_stage_lat", + [this, idx]() -> seastar::metrics::histogram& { + return stats.stage_lat[idx]; + }, + sm::description("per-stage latency (microseconds) of do_transaction"), + {label, sm::label_instance("shard_store_index", std::to_string(store_index))} + ) + } + ); + } + metrics.add_group( "seastore", { @@ -1667,15 +1696,19 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( --(shard_stats.starting_io_num); ++(shard_stats.waiting_collock_io_num); + auto t_pre_collock = std::chrono::steady_clock::now(); co_await ctx.transaction->get_handle().take_collection_lock( static_cast(*(ctx.ch)).ordering_lock ); + auto collock_wait = std::chrono::steady_clock::now() - t_pre_collock; assert(shard_stats.waiting_collock_io_num); --(shard_stats.waiting_collock_io_num); ++(shard_stats.waiting_throttler_io_num); + auto t_pre_throttler = std::chrono::steady_clock::now(); co_await throttler.get(1); + auto throttler_wait = std::chrono::steady_clock::now() - t_pre_throttler; assert(shard_stats.waiting_throttler_io_num); --(shard_stats.waiting_throttler_io_num); @@ -1711,6 +1744,7 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( const size_t total_ops = ctx.ext_transaction.get_num_ops(); size_t current_op = 0; + auto build_start = std::chrono::steady_clock::now(); while (ctx.iter.have_op()) { current_op++; @@ -1719,10 +1753,13 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( co_await _do_transaction_step( ctx, ctx.ch, onodes, ctx.iter); } + ctx.build_time += std::chrono::steady_clock::now() - build_start; DEBUGT("completed all {} ops for cid={}", t, total_ops, ctx.ch->get_cid()); + auto submit_start = std::chrono::steady_clock::now(); co_await transaction_manager->submit_transaction(*ctx.transaction); + ctx.submit_time += std::chrono::steady_clock::now() - submit_start; }) ).handle_error( crimson::ct_error::all_same_way([FNAME, &ctx](auto e) { @@ -1734,6 +1771,10 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( DEBUGT("done", *ctx.transaction); add_conflict_replay_sample(ctx.transaction->get_num_replays()); + add_stage_latency_sample(txn_stage_t::COLLOCK_WAIT, collock_wait); + add_stage_latency_sample(txn_stage_t::THROTTLER_WAIT, throttler_wait); + add_stage_latency_sample(txn_stage_t::BUILD, ctx.build_time); + add_stage_latency_sample(txn_stage_t::SUBMIT, ctx.submit_time); add_latency_sample( op_type_t::DO_TRANSACTION, std::chrono::steady_clock::now() - ctx.begin_timestamp); diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index e2bbc576a43..61c59b8d554 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -47,6 +47,14 @@ enum class op_type_t : uint8_t { MAX }; +enum class txn_stage_t : uint8_t { + COLLOCK_WAIT = 0, // waiting on the collection ordering_lock + THROTTLER_WAIT, // waiting for a throttler slot + BUILD, // building the transaction (_do_transaction_step loop) + SUBMIT, // submit_transaction (pipeline + journal write) + MAX +}; + class SeastoreCollection final : public FuturizedCollection { public: template @@ -252,6 +260,9 @@ public: ceph::os::Transaction::iterator iter; std::chrono::steady_clock::time_point begin_timestamp = std::chrono::steady_clock::now(); + std::chrono::steady_clock::duration build_time{0}; + std::chrono::steady_clock::duration submit_time{0}; + void reset_preserve_handle(TransactionManager &tm) { tm.reset_transaction_preserve_handle(*transaction); iter = ext_transaction.begin(); @@ -440,9 +451,17 @@ public: // Buckets for the per-transaction conflict/replay distribution. static constexpr std::size_t REPLAY_BUCKETS = 16; + static constexpr auto STAGE_MAX = static_cast(txn_stage_t::MAX); + // Upper bounds (microseconds) for the per-stage do_transaction latency histograms + static constexpr std::array STAGE_LAT_BUCKETS_US = { + 250, 500, 1000, 1500, 2000, 3000, 5000, 7500, + 10000, 15000, 20000, 30000, 50000, 100000 + }; + struct { std::array op_lat; seastar::metrics::histogram conflict_replays; + std::array stage_lat; } stats; seastar::metrics::histogram& get_latency( @@ -485,6 +504,27 @@ public: hist.sample_sum += num_replays; } + // Record the latency of one do_transaction stage (microseconds). Buckets are + // non-cumulative (bucket = first upper_bound >= value); values above the top + // bound aren't bucketed but still land in sample_count/sample_sum. + void add_stage_latency_sample(txn_stage_t stage, + std::chrono::steady_clock::duration dur) { + auto& hist = stats.stage_lat[static_cast(stage)]; + if (hist.buckets.empty()) { + // register_metrics() did not run (store inactive); nothing to record. + return; + } + auto us = std::chrono::duration_cast(dur).count(); + for (auto& b : hist.buckets) { + if (static_cast(us) <= b.upper_bound) { + ++b.count; + break; + } + } + ++hist.sample_count; + hist.sample_sum += us; + } + /* * omaptree interfaces */ From 6f7f9779e787c1feef98090710cd0c162d41b2ef Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Sun, 7 Jun 2026 12:11:49 +0000 Subject: [PATCH 109/596] crimson/os/seastore: break down submit phases Signed-off-by: Matan Breizman --- src/crimson/os/seastore/seastore.cc | 25 +++++++++++++++---- src/crimson/os/seastore/seastore.h | 9 ++++++- src/crimson/os/seastore/transaction.h | 16 ++++++++++++ .../os/seastore/transaction_manager.cc | 21 ++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index 2464032ffae..3d88bb1c405 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -226,10 +226,16 @@ void SeaStore::Shard::register_metrics(store_index_t store_index) ); std::pair labels_by_stage[] = { - {txn_stage_t::COLLOCK_WAIT, sm::label_instance("stage", "collock_wait")}, - {txn_stage_t::THROTTLER_WAIT, sm::label_instance("stage", "throttler_wait")}, - {txn_stage_t::BUILD, sm::label_instance("stage", "build")}, - {txn_stage_t::SUBMIT, sm::label_instance("stage", "submit")}, + {txn_stage_t::COLLOCK_WAIT, sm::label_instance("stage", "collock_wait")}, + {txn_stage_t::THROTTLER_WAIT, sm::label_instance("stage", "throttler_wait")}, + {txn_stage_t::BUILD, sm::label_instance("stage", "build")}, + {txn_stage_t::SUBMIT_TOTAL, sm::label_instance("stage", "submit_total")}, + {txn_stage_t::SUBMIT_RESERVE, sm::label_instance("stage", "submit_reserve")}, + {txn_stage_t::SUBMIT_OOL_WRITE, sm::label_instance("stage", "submit_ool_write")}, + {txn_stage_t::SUBMIT_LBA_UPDATE, sm::label_instance("stage", "submit_lba_update")}, + {txn_stage_t::SUBMIT_PREPARE_ENTER, sm::label_instance("stage", "submit_prepare_enter")}, + {txn_stage_t::SUBMIT_PREPARE_RECORD, sm::label_instance("stage", "submit_prepare_record")}, + {txn_stage_t::SUBMIT_JOURNAL, sm::label_instance("stage", "submit_journal")}, }; for (auto& [stage, label] : labels_by_stage) { auto idx = static_cast(stage); @@ -1774,7 +1780,16 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( add_stage_latency_sample(txn_stage_t::COLLOCK_WAIT, collock_wait); add_stage_latency_sample(txn_stage_t::THROTTLER_WAIT, throttler_wait); add_stage_latency_sample(txn_stage_t::BUILD, ctx.build_time); - add_stage_latency_sample(txn_stage_t::SUBMIT, ctx.submit_time); + add_stage_latency_sample(txn_stage_t::SUBMIT_TOTAL, ctx.submit_time); + { + auto& pd = ctx.transaction->get_phase_durations(); + add_stage_latency_sample(txn_stage_t::SUBMIT_RESERVE, pd.reserve); + add_stage_latency_sample(txn_stage_t::SUBMIT_OOL_WRITE, pd.ool_write); + add_stage_latency_sample(txn_stage_t::SUBMIT_LBA_UPDATE, pd.lba_update); + add_stage_latency_sample(txn_stage_t::SUBMIT_PREPARE_ENTER, pd.prepare_enter); + add_stage_latency_sample(txn_stage_t::SUBMIT_PREPARE_RECORD, pd.prepare_record); + add_stage_latency_sample(txn_stage_t::SUBMIT_JOURNAL, pd.journal); + } add_latency_sample( op_type_t::DO_TRANSACTION, std::chrono::steady_clock::now() - ctx.begin_timestamp); diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index 61c59b8d554..0cb63205052 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -51,7 +51,14 @@ enum class txn_stage_t : uint8_t { COLLOCK_WAIT = 0, // waiting on the collection ordering_lock THROTTLER_WAIT, // waiting for a throttler slot BUILD, // building the transaction (_do_transaction_step loop) - SUBMIT, // submit_transaction (pipeline + journal write) + SUBMIT_TOTAL, // the whole submit_transaction (pipeline + journal write) + // Sub-phases of submit_transaction: + SUBMIT_RESERVE, // enter(reserve_projected_usage) + epm reserve_projected_usage + SUBMIT_OOL_WRITE, // write_delayed + write_preallocated OOL extents (device I/O) + SUBMIT_LBA_UPDATE, // update_lba_mappings + SUBMIT_PREPARE_ENTER, // enter(prepare) pipeline stage (global OrderedExclusive wait) + SUBMIT_PREPARE_RECORD, // prepare_record (record encoding) + SUBMIT_JOURNAL, // journal->submit_record -- POST-lock (not part of the hold) MAX }; diff --git a/src/crimson/os/seastore/transaction.h b/src/crimson/os/seastore/transaction.h index 8fa2d011f5e..3c2be49db9a 100644 --- a/src/crimson/os/seastore/transaction.h +++ b/src/crimson/os/seastore/transaction.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include @@ -470,6 +471,19 @@ public: return num_replays; } + // Time spent in each sub-phase of submit_transaction, accumulated across retries + struct phase_durations_t { + std::chrono::steady_clock::duration reserve{0}; // enter reserve + epm reserve + std::chrono::steady_clock::duration ool_write{0}; // delayed + preallocated OOL writes + std::chrono::steady_clock::duration lba_update{0}; // update_lba_mappings + std::chrono::steady_clock::duration prepare_enter{0}; // enter(prepare) pipeline stage + std::chrono::steady_clock::duration prepare_record{0}; // prepare_record + std::chrono::steady_clock::duration journal{0}; // journal->submit_record (post-lock) + }; + phase_durations_t &get_phase_durations() { + return phase_durations; + } + auto &get_handle() { return handle; } @@ -890,6 +904,8 @@ private: std::size_t num_replays = 0; + phase_durations_t phase_durations; + bool has_reset = false; OrderingHandle handle; diff --git a/src/crimson/os/seastore/transaction_manager.cc b/src/crimson/os/seastore/transaction_manager.cc index af5d32930ac..f0fd3509cea 100644 --- a/src/crimson/os/seastore/transaction_manager.cc +++ b/src/crimson/os/seastore/transaction_manager.cc @@ -471,6 +471,7 @@ TransactionManager::submit_transaction( { LOG_PREFIX(TransactionManager::submit_transaction); SUBDEBUGT(seastore_t, "start, entering reserve_projected_usage", t); + auto reserve_start = std::chrono::steady_clock::now(); co_await trans_intr::make_interruptible( t.get_handle().enter(write_pipeline.reserve_projected_usage) ); @@ -483,6 +484,8 @@ TransactionManager::submit_transaction( projected_usage ) ); + t.get_phase_durations().reserve += + std::chrono::steady_clock::now() - reserve_start; auto release_usage = seastar::defer([this, FNAME, projected_usage, &t] { SUBTRACET(seastore_t, "releasing projected_usage: {}", t, projected_usage); epm->release_projected_usage(projected_usage); @@ -593,21 +596,33 @@ TransactionManager::do_submit_transaction( ); SUBTRACET(seastore_t, "write delayed ool extents", tref); + auto ool_start = std::chrono::steady_clock::now(); co_await epm->write_delayed_ool_extents( tref, dispatch_result.alloc_map ); + tref.get_phase_durations().ool_write += + std::chrono::steady_clock::now() - ool_start; auto allocated_extents = tref.get_valid_pre_alloc_list(); + auto lba_start = std::chrono::steady_clock::now(); co_await update_lba_mappings(tref, allocated_extents); + tref.get_phase_durations().lba_update += + std::chrono::steady_clock::now() - lba_start; auto num_extents = allocated_extents.size(); SUBTRACET(seastore_t, "process {} allocated extents", tref, num_extents); + ool_start = std::chrono::steady_clock::now(); co_await epm->write_preallocated_ool_extents(tref, allocated_extents); + tref.get_phase_durations().ool_write += + std::chrono::steady_clock::now() - ool_start; SUBTRACET(seastore_t, "entering prepare", tref); + auto prepare_enter_start = std::chrono::steady_clock::now(); co_await trans_intr::make_interruptible( tref.get_handle().enter(write_pipeline.prepare) ); + tref.get_phase_durations().prepare_enter += + std::chrono::steady_clock::now() - prepare_enter_start; while (tref.need_wait_visibility) { co_await trans_intr::make_interruptible(seastar::yield()); @@ -617,10 +632,13 @@ TransactionManager::do_submit_transaction( cache->trim_backref_bufs(*trim_alloc_to); } + auto prepare_record_start = std::chrono::steady_clock::now(); auto record = cache->prepare_record( tref, journal->get_trimmer().get_journal_head(), journal->get_trimmer().get_dirty_tail()); + tref.get_phase_durations().prepare_record += + std::chrono::steady_clock::now() - prepare_record_start; tref.get_handle().maybe_release_collection_lock(); if (tref.get_src() == Transaction::src_t::MUTATE) { @@ -629,6 +647,7 @@ TransactionManager::do_submit_transaction( } SUBTRACET(seastore_t, "submitting record", tref); + auto journal_start = std::chrono::steady_clock::now(); co_await journal->submit_record( std::move(record), tref.get_handle(), @@ -648,6 +667,8 @@ TransactionManager::do_submit_transaction( submit_transaction_iertr::pass_further{}, crimson::ct_error::assert_all("Hit error submitting to journal") ); + tref.get_phase_durations().journal += + std::chrono::steady_clock::now() - journal_start; co_await trans_intr::make_interruptible( tref.get_handle().complete() From 63b5a5aebd5c39969a4df21dfbbac5242f95f936 Mon Sep 17 00:00:00 2001 From: gmallet Date: Mon, 8 Jun 2026 08:00:31 +0200 Subject: [PATCH 110/596] libcephfs: add public api for ceph_ll_flock Signed-off-by: gmallet --- src/include/cephfs/libcephfs.h | 2 ++ src/libcephfs.cc | 6 ++++ src/test/libcephfs/flock.cc | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/src/include/cephfs/libcephfs.h b/src/include/cephfs/libcephfs.h index 12c202ae12e..2372e92eb2f 100644 --- a/src/include/cephfs/libcephfs.h +++ b/src/include/cephfs/libcephfs.h @@ -2221,6 +2221,8 @@ int ceph_ll_getlk(struct ceph_mount_info *cmount, Fh *fh, struct flock *fl, uint64_t owner); int ceph_ll_setlk(struct ceph_mount_info *cmount, Fh *fh, struct flock *fl, uint64_t owner, int sleep); +int ceph_ll_flock(struct ceph_mount_info *cmount, + Fh *fh, int operation, uint64_t owner); int ceph_ll_lazyio(struct ceph_mount_info *cmount, Fh *fh, int enable); diff --git a/src/libcephfs.cc b/src/libcephfs.cc index 0eeddd7167e..e24750dfed7 100644 --- a/src/libcephfs.cc +++ b/src/libcephfs.cc @@ -2395,6 +2395,12 @@ extern "C" int ceph_ll_setlk(struct ceph_mount_info *cmount, return (cmount->get_client()->ll_setlk(fh, fl, owner, sleep)); } +extern "C" int ceph_ll_flock(struct ceph_mount_info *cmount, + Fh *fh, int operation, uint64_t owner) +{ + return (cmount->get_client()->ll_flock(fh, operation, owner)); +} + extern "C" int ceph_ll_lazyio(class ceph_mount_info *cmount, Fh *fh, int enable) { diff --git a/src/test/libcephfs/flock.cc b/src/test/libcephfs/flock.cc index 3ced2d1bcec..52bce20f2ec 100644 --- a/src/test/libcephfs/flock.cc +++ b/src/test/libcephfs/flock.cc @@ -130,6 +130,62 @@ TEST(LibCephFS, BasicLocking) { CLEANUP_CEPH(); } +TEST(LibCephFS, BasicLLLocking) { + struct ceph_mount_info *cmount = NULL; + STARTUP_CEPH(); + + char c_file[1024]; + sprintf(c_file, "ll_flock_test_%d", getpid()); + Fh *fh = NULL; + Inode *root = NULL, *inode = NULL; + struct ceph_statx stx; + UserPerm *perms = ceph_mount_perms(cmount); + + ASSERT_EQ(0, ceph_ll_lookup_root(cmount, &root)); + ASSERT_EQ(0, ceph_ll_create(cmount, root, c_file, fileMode, + O_RDWR | O_CREAT, &inode, &fh, &stx, + 0, 0, perms)); + + // Lock exclusively twice + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_EX, 42)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 43)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 44)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 42)); + + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 43)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 44)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 43)); + + // Lock shared three times + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_SH, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_SH, 43)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_SH, 44)); + // And then attempt to lock exclusively + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 45)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 42)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 45)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 44)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 45)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 43)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_EX | LOCK_NB, 45)); + ASSERT_EQ(-EAGAIN, ceph_ll_flock(cmount, fh, LOCK_SH | LOCK_NB, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 45)); + + // Lock shared with upgrade to exclusive (POSIX) + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_SH, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_EX, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 42)); + + // Lock exclusive with downgrade to shared (POSIX) + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_EX, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_SH, 42)); + ASSERT_EQ(0, ceph_ll_flock(cmount, fh, LOCK_UN, 42)); + + ASSERT_EQ(0, ceph_ll_close(cmount, fh)); + ASSERT_EQ(0, ceph_ll_unlink(cmount, root, c_file, perms)); + CLEANUP_CEPH(); +} + /* Locking in different threads */ // Used by ConcurrentLocking test From b05cfd16126527cb4178130344334f8dee1f3748 Mon Sep 17 00:00:00 2001 From: Jon Bailey Date: Thu, 4 Jun 2026 11:27:07 +0100 Subject: [PATCH 111/596] test: Remove invalid unit test This test was talking about testing invalid ops, however with the inclusion of sync reads in EC (https://github.com/ceph/ceph/pull/67079), it is valid to perform class reads in EC. In addition, work was done around illegal ops here: https://github.com/ceph/ceph/pull/66258 and the existance of TEST(ClsHello, BadMethods) in test_cls_hello.cc covers illegal ops in that PR leading me to think this is unneccisairy. Because of these reasons, I think its better this test is removed as it is incorrect and also not working. Signed-off-by: Jon Bailey --- src/test/librados/split_op_cxx.cc | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/test/librados/split_op_cxx.cc b/src/test/librados/split_op_cxx.cc index e71318f1590..4abc416bb92 100644 --- a/src/test/librados/split_op_cxx.cc +++ b/src/test/librados/split_op_cxx.cc @@ -322,25 +322,6 @@ TEST_P(LibRadosSplitOpECPP, ReadSecondShardWithVersion) { ASSERT_TRUE(AssertOperateWithSplitOp(0, 2, "foo", &read, &bl, balanced_read_flags)); } -TEST_P(LibRadosSplitOpECPP, ReadWithIllegalClsOp) { - SKIP_IF_CRIMSON(); - bufferlist bl; - bl.append("ceph"); - ObjectWriteOperation write1; - write1.write(0, bl); - ASSERT_TRUE(AssertOperateWithoutSplitOp(0, "foo", &write1)); - - bufferlist new_bl; - new_bl.append("CEPH"); - ObjectWriteOperation write2; - bufferlist exec_inbl, exec_outbl; - int exec_rval; - rados::cls::fifo::op::init_part op; - encode(op, exec_inbl); - write2.exec(fifo::method::init_part, exec_inbl, &exec_outbl, &exec_rval); - ASSERT_TRUE(AssertOperateWithoutSplitOp(-EOPNOTSUPP, "foo", &write2)); -} - TEST_P(LibRadosSplitOpECPP, XattrReads) { SKIP_IF_CRIMSON(); bufferlist bl, attr_bl, attr_read_bl; From c7bbe58ae7a89b6a8db818fe704042c80c6b5682 Mon Sep 17 00:00:00 2001 From: Alex Ainscow Date: Sun, 3 May 2026 17:46:00 +0100 Subject: [PATCH 112/596] test/osd: Improve test logging with OSD identification Modified the logging system to show which OSD is executing in test logs, making it easier to debug multi-OSD test scenarios. Changes: - Added Log::set_prefix_hook() to allow tests to customize log prefixes - Modified Log::_flush() to use prefix_hook when set, replacing thread IDs - Added EventLoop::get_log_prefix() to return 'osd.X' or 'harness' based on context - Updated EventLoop to track current_executing_osd during event execution - Simplified PGBackendTestFixture to use NoDoutPrefix directly - Removed redundant 'shard X:' prefix from ShardDpp (info already in PG identifier) - Removed verbose console output from EventLoop (now redundant with OSD prefixes) - Converted EventLoop message scheduling to use dout at level 20 Benefits: - Test logs now show 'osd.X' instead of thread IDs during event execution - Shows 'harness' for test setup/teardown code outside events - Message scheduling visible in logs at debug level 20 - Makes multi-OSD test scenarios much easier to follow and debug - No changes to production logging code AI assisted code, with careful human review and checking. AI-assisted-by: IBM Bob IDE:Claude Sonnet (Anthropic) Signed-off-by: Alex Ainscow --- src/log/Log.cc | 20 +++- src/log/Log.h | 9 ++ src/test/osd/ECPeeringTestFixture.cc | 1 - src/test/osd/EventLoop.h | 142 +++++++++------------------ src/test/osd/PGBackendTestFixture.h | 23 +++-- 5 files changed, 85 insertions(+), 110 deletions(-) diff --git a/src/log/Log.cc b/src/log/Log.cc index 989438abbfd..4a79f0def28 100644 --- a/src/log/Log.cc +++ b/src/log/Log.cc @@ -40,6 +40,11 @@ namespace logging { static OnExitManager exit_callbacks; +// Static hook for getting log prefix (set by tests). +// NOTE: Not thread-safe. Intended for single-threaded unit test harnesses +// and should be set once during startup. +static Log::prefix_hook_t prefix_hook = nullptr; + static void log_on_exit(void *p) { Log *l = *(Log **)p; @@ -48,6 +53,11 @@ static void log_on_exit(void *p) delete (Log **)p;// Delete allocated pointer (not Log object, the pointer only!) } +void Log::set_prefix_hook(prefix_hook_t hook) +{ + prefix_hook = hook; +} + Log::Log(const SubsystemMap *s) : m_indirect_this(nullptr), m_subs(s), @@ -409,7 +419,15 @@ void Log::_flush(EntryVector& t, bool crash) used += (std::size_t)snprintf(pos + used, allocated - used, "%6ld> ", -(--len)); } used += (std::size_t)append_time(stamp, pos + used, allocated - used); - used += (std::size_t)snprintf(pos + used, allocated - used, " %lx %2d ", (unsigned long)thread, prio); + + // In tests, replace thread ID with custom prefix (e.g., "osd.X" or "harness") + const char* prefix = prefix_hook ? prefix_hook() : nullptr; + if (prefix) { + used += (std::size_t)snprintf(pos + used, allocated - used, " %s %2d ", prefix, prio); + } else { + used += (std::size_t)snprintf(pos + used, allocated - used, " %lx %2d ", (unsigned long)thread, prio); + } + memcpy(pos + used, str.data(), str.size()); used += str.size(); pos[used] = '\0'; diff --git a/src/log/Log.h b/src/log/Log.h index 99b636eb13a..226afbd78ca 100644 --- a/src/log/Log.h +++ b/src/log/Log.h @@ -35,6 +35,7 @@ class Log : private Thread { public: using Thread::is_started; + using prefix_hook_t = const char* (*)(); Log(const SubsystemMap *s); ~Log() override; @@ -81,6 +82,14 @@ public: void inject_segv(); void reset_segv(); + /** + * Set a hook to get the log prefix (replaces thread ID in log output). + * + * @note Not thread-safe. Must be called once during startup before any + * logging occurs. Designed for single-threaded unit test harnesses only. + */ + static void set_prefix_hook(prefix_hook_t hook); + protected: using EntryVector = std::vector; diff --git a/src/test/osd/ECPeeringTestFixture.cc b/src/test/osd/ECPeeringTestFixture.cc index 172aa9a8dfb..5a61f381b78 100644 --- a/src/test/osd/ECPeeringTestFixture.cc +++ b/src/test/osd/ECPeeringTestFixture.cc @@ -19,7 +19,6 @@ // ShardDpp implementation std::ostream& ECPeeringTestFixture::ShardDpp::gen_prefix(std::ostream& out) const { - out << "shard " << shard << ": "; if (fixture->shard_peering_states.contains(shard)) { PeeringState *ps = fixture->shard_peering_states[shard].get(); out << *ps; diff --git a/src/test/osd/EventLoop.h b/src/test/osd/EventLoop.h index c54d3fb603d..f1eec6c7c7f 100644 --- a/src/test/osd/EventLoop.h +++ b/src/test/osd/EventLoop.h @@ -15,7 +15,6 @@ #pragma once -#include #include #include #include @@ -27,6 +26,7 @@ #include "osd/OpRequest.h" #include "osd/PeeringState.h" #include "os/ObjectStore.h" +#include "common/dout.h" /** * EventLoop - Unified single-threaded event loop for OSD tests. @@ -48,7 +48,37 @@ public: PEERING_EVENT }; + /** + * Get the log prefix for the currently executing context. + * Returns "osd.X" if an OSD is currently executing, "harness" if not. + * This is used by Log::_flush() to replace thread IDs with meaningful names. + */ + static const char* get_log_prefix() { + static thread_local char prefix_buf[32]; + + // Read the value once to ensure consistency within this call + int osd_id = current_executing_osd; + + if (osd_id >= 0) { + snprintf(prefix_buf, sizeof(prefix_buf), "osd.%d", osd_id); + return prefix_buf; + } + // osd_id is -1 (harness/test code, not in OSD event) + return "harness"; + } + + /** + * Get the OSD number currently executing in the EventLoop. + * Returns -1 if no OSD is currently executing or if called outside an EventLoop context. + * This is a simple static variable that can be easily accessed from lldb at any time. + */ + static int get_current_executing_osd() { + return current_executing_osd; + } + private: + // Static storage for the currently executing OSD + static int current_executing_osd; struct Event { EventType type; int from_osd; // -1 for generic events or when not applicable @@ -60,13 +90,13 @@ private: }; std::deque events; - bool verbose = false; int events_executed = 0; std::map events_by_type; std::set suspended_from_osds; std::set suspended_to_osds; std::set> suspended_from_to_osds; int current_osd = -1; + DoutPrefixProvider *dpp; // Map from OSD number to its queue of suspended events std::map> suspended_events; @@ -87,7 +117,7 @@ private: } public: - EventLoop(bool verbose = false) : verbose(verbose) {} + EventLoop(DoutPrefixProvider *dpp) : dpp(dpp) {} void schedule_generic(GenericEvent event) { events.emplace_back(EventType::GENERIC, -1, -1, std::move(event)); @@ -96,6 +126,8 @@ public: void schedule_osd_message(int from_osd, int to_osd, GenericEvent callback) { ceph_assert(from_osd >= 0); ceph_assert(to_osd >= 0); + ldpp_dout(dpp, 20) << "EventLoop: scheduling OSD_MESSAGE from OSD." << from_osd + << " to OSD." << to_osd << dendl; events.emplace_back(EventType::OSD_MESSAGE, from_osd, to_osd, std::move(callback)); } @@ -107,12 +139,16 @@ public: void schedule_peering_message(int from_osd, int to_osd, GenericEvent callback) { ceph_assert(from_osd >= 0); ceph_assert(to_osd >= 0); + ldpp_dout(dpp, 20) << "EventLoop: scheduling PEERING_MESSAGE from OSD." << from_osd + << " to OSD." << to_osd << dendl; events.emplace_back(EventType::PEERING_MESSAGE, from_osd, to_osd, std::move(callback)); } void schedule_cluster_message(int from_osd, int to_osd, GenericEvent callback) { ceph_assert(from_osd >= 0); ceph_assert(to_osd >= 0); + ldpp_dout(dpp, 20) << "EventLoop: scheduling CLUSTER_MESSAGE from OSD." << from_osd + << " to OSD." << to_osd << dendl; events.emplace_back(EventType::CLUSTER_MESSAGE, from_osd, to_osd, std::move(callback)); } @@ -172,68 +208,32 @@ public: if (should_suspend) { // Move to suspended queue - if (verbose) { - std::cout << " [Event " << (events_executed + 1) << "] " - << event_type_name(event.type); - if (event.from_osd >= 0 && event.to_osd >= 0) { - std::cout << " (OSD." << event.from_osd << " -> OSD." << event.to_osd << ")"; - } else if (event.to_osd >= 0) { - std::cout << " (to OSD." << event.to_osd << ")"; - } else if (event.from_osd >= 0) { - std::cout << " (from OSD." << event.from_osd << ")"; - } - std::cout << " *** SUSPENDED - moving to suspended queue ***" << std::endl; - } suspended_events[suspend_osd].push_back(std::move(event)); return true; // We processed an event (by suspending it) } - // Print banner if switching to a different OSD + // Track which OSD we're currently processing int active_osd = (event.to_osd >= 0) ? event.to_osd : event.from_osd; if (active_osd >= 0 && active_osd != current_osd) { current_osd = active_osd; - if (verbose) { - std::cout << "\n==== Processing events for OSD." << current_osd - << " ====" << std::endl; - } } - if (verbose) { - std::cout << " [Event " << (events_executed + 1) << "] " - << event_type_name(event.type); - if (event.from_osd >= 0 && event.to_osd >= 0) { - std::cout << " (OSD." << event.from_osd << " -> OSD." << event.to_osd << ")"; - } else if (event.to_osd >= 0) { - std::cout << " (to OSD." << event.to_osd << ")"; - } else if (event.from_osd >= 0) { - std::cout << " (from OSD." << event.from_osd << ")"; - } - std::cout << " Executing..." << std::endl; - } + current_executing_osd = active_osd; // Execute the event event.callback(); events_executed++; events_by_type[event.type]++; + current_executing_osd = -1; return true; } int run_many(int count) { - if (verbose) { - std::cout << "\n=== Running " << count << " events ===" << std::endl; - } - int executed = 0; for (int i = 0; i < count && run_one(); i++) { executed++; } - - if (verbose) { - std::cout << "=== Executed " << executed << " events, " - << events.size() << " remaining ===" << std::endl; - } - return executed; } @@ -253,14 +253,6 @@ public: * Returns -1 if max_events was reached before the queue emptied. */ void run_until_idle(int max_events = 10000) { - if (verbose) { - std::cout << "\n=== Running until idle"; - if (max_events > 0) { - std::cout << " (max " << max_events << " events)"; - } - std::cout << " ===" << std::endl; - } - do { ceph_assert(--max_events); while (has_events()) { @@ -274,10 +266,6 @@ public: suspended_events.clear(); } - void set_verbose(bool v) { - verbose = v; - } - // OSD management methods /** * Suspend events FROM an OSD - events originating from this OSD will be queued @@ -285,9 +273,6 @@ public: */ void suspend_from_osd(int osd) { suspended_from_osds.insert(osd); - if (verbose) { - std::cout << "*** Events FROM OSD." << osd << " marked as SUSPENDED ***" << std::endl; - } } /** @@ -300,21 +285,11 @@ public: // Move all suspended events for this OSD back to the main queue auto it = suspended_events.find(osd); if (it != suspended_events.end()) { - if (verbose) { - std::cout << "*** Events FROM OSD." << osd << " marked as UNSUSPENDED - restoring " - << it->second.size() << " suspended events ***" << std::endl; - } - // Append suspended events to the main queue for (auto& event : it->second) { events.push_back(std::move(event)); } - suspended_events.erase(it); - } else { - if (verbose) { - std::cout << "*** Events FROM OSD." << osd << " marked as UNSUSPENDED ***" << std::endl; - } } } @@ -324,9 +299,6 @@ public: */ void suspend_to_osd(int osd) { suspended_to_osds.insert(osd); - if (verbose) { - std::cout << "*** Events TO OSD." << osd << " marked as SUSPENDED ***" << std::endl; - } } /** @@ -339,21 +311,11 @@ public: // Move all suspended events for this OSD back to the main queue auto it = suspended_events.find(osd); if (it != suspended_events.end()) { - if (verbose) { - std::cout << "*** Events TO OSD." << osd << " marked as UNSUSPENDED - restoring " - << it->second.size() << " suspended events ***" << std::endl; - } - // Append suspended events to the main queue for (auto& event : it->second) { events.push_back(std::move(event)); } - suspended_events.erase(it); - } else { - if (verbose) { - std::cout << "*** Events TO OSD." << osd << " marked as UNSUSPENDED ***" << std::endl; - } } } @@ -363,10 +325,6 @@ public: */ void suspend_from_to_osd(int from_osd, int to_osd) { suspended_from_to_osds.insert({from_osd, to_osd}); - if (verbose) { - std::cout << "*** Events FROM OSD." << from_osd << " TO OSD." << to_osd - << " marked as SUSPENDED ***" << std::endl; - } } /** @@ -379,23 +337,11 @@ public: // Move all suspended events for this OSD pair back to the main queue auto it = suspended_events.find(to_osd); if (it != suspended_events.end()) { - if (verbose) { - std::cout << "*** Events FROM OSD." << from_osd << " TO OSD." << to_osd - << " marked as UNSUSPENDED - restoring " - << it->second.size() << " suspended events ***" << std::endl; - } - // Append suspended events to the main queue for (auto& event : it->second) { events.push_back(std::move(event)); } - suspended_events.erase(it); - } else { - if (verbose) { - std::cout << "*** Events FROM OSD." << from_osd << " TO OSD." << to_osd - << " marked as UNSUSPENDED ***" << std::endl; - } } } @@ -437,3 +383,7 @@ public: } }; +// Define the static variable +// Initialized to -1 (no OSD currently executing) +inline int EventLoop::current_executing_osd = -1; + diff --git a/src/test/osd/PGBackendTestFixture.h b/src/test/osd/PGBackendTestFixture.h index 0269f9c3c26..0e728f3f38c 100644 --- a/src/test/osd/PGBackendTestFixture.h +++ b/src/test/osd/PGBackendTestFixture.h @@ -109,16 +109,7 @@ protected: // The epoch comes from osdmap, this tracks the second version number uint64_t next_version = 1; - class TestDpp : public NoDoutPrefix { - public: - TestDpp(CephContext *cct) : NoDoutPrefix(cct, ceph_subsys_osd) {} - - std::ostream& gen_prefix(std::ostream& out) const override { - out << "PGBackendTest: "; - return out; - } - }; - std::unique_ptr dpp; + std::unique_ptr dpp; public: explicit PGBackendTestFixture(PoolType type = EC) : pool_type(type) @@ -142,6 +133,7 @@ public: } void SetUp() override { + ceph::logging::Log::set_prefix_hook(&EventLoop::get_log_prefix); int r = ::mkdir(data_dir.c_str(), 0777); if (r < 0) { r = -errno; @@ -158,8 +150,14 @@ public: g_conf().set_safe_to_start_threads(); CephContext *cct = g_ceph_context; - dpp = std::make_unique(cct); - event_loop = std::make_unique(false); + + // Make dout statements flush immediately - we don't care about performance in tests + if (cct->_log) { + cct->_log->set_max_new(1); + } + + dpp = std::make_unique(cct, ceph_subsys_osd); + event_loop = std::make_unique(dpp.get()); if (pool_type == EC) { setup_ec_pool(); @@ -208,6 +206,7 @@ public: } cleanup_data_dir(); + ceph::logging::Log::set_prefix_hook(nullptr); } private: From ca0bddb89123733284c6a9cd6a1c1e26f4ec1757 Mon Sep 17 00:00:00 2001 From: Matan Breizman Date: Mon, 8 Jun 2026 15:02:27 +0000 Subject: [PATCH 113/596] crimson/os/seastore: track onode lookup latency in do_transaction we might be able to reduce onode lookup stage by storing the prev lookup already made when getting the obc. according to to the stats I've colleceted onode lookup takes 40% of total build time (which is ~20% of total txn time) Signed-off-by: Matan Breizman --- src/crimson/os/seastore/seastore.cc | 7 +++++++ src/crimson/os/seastore/seastore.h | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index 3d88bb1c405..6c77820ba43 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -229,6 +229,7 @@ void SeaStore::Shard::register_metrics(store_index_t store_index) {txn_stage_t::COLLOCK_WAIT, sm::label_instance("stage", "collock_wait")}, {txn_stage_t::THROTTLER_WAIT, sm::label_instance("stage", "throttler_wait")}, {txn_stage_t::BUILD, sm::label_instance("stage", "build")}, + {txn_stage_t::BUILD_GET_ONODE, sm::label_instance("stage", "build_get_onode")}, {txn_stage_t::SUBMIT_TOTAL, sm::label_instance("stage", "submit_total")}, {txn_stage_t::SUBMIT_RESERVE, sm::label_instance("stage", "submit_reserve")}, {txn_stage_t::SUBMIT_OOL_WRITE, sm::label_instance("stage", "submit_ool_write")}, @@ -1780,6 +1781,7 @@ seastar::future<> SeaStore::Shard::do_transaction_no_callbacks( add_stage_latency_sample(txn_stage_t::COLLOCK_WAIT, collock_wait); add_stage_latency_sample(txn_stage_t::THROTTLER_WAIT, throttler_wait); add_stage_latency_sample(txn_stage_t::BUILD, ctx.build_time); + add_stage_latency_sample(txn_stage_t::BUILD_GET_ONODE, ctx.get_onode_time); add_stage_latency_sample(txn_stage_t::SUBMIT_TOTAL, ctx.submit_time); { auto& pd = ctx.transaction->get_phase_durations(); @@ -1893,6 +1895,7 @@ SeaStore::Shard::_do_transaction_step( } if (!onodes[op->oid]) { const ghobject_t& oid = i.get_oid(op->oid); + auto t0 = std::chrono::steady_clock::now(); if (!create) { DEBUGT("op {}, get oid={} ...", *ctx.transaction, (uint32_t)op->op, oid); @@ -1902,6 +1905,10 @@ SeaStore::Shard::_do_transaction_step( *ctx.transaction, (uint32_t)op->op, oid); fut = onode_manager->get_or_create_onode(*ctx.transaction, oid); } + fut = std::move(fut).si_then([&ctx, t0](auto onode) { + ctx.get_onode_time += std::chrono::steady_clock::now() - t0; + return onode_iertr::make_ready_future(std::move(onode)); + }); } return fut.si_then([&, op, this, FNAME](auto get_onode) -> OnodeManager::get_or_create_onode_iertr::future<> { diff --git a/src/crimson/os/seastore/seastore.h b/src/crimson/os/seastore/seastore.h index 0cb63205052..56277b405dd 100644 --- a/src/crimson/os/seastore/seastore.h +++ b/src/crimson/os/seastore/seastore.h @@ -51,6 +51,7 @@ enum class txn_stage_t : uint8_t { COLLOCK_WAIT = 0, // waiting on the collection ordering_lock THROTTLER_WAIT, // waiting for a throttler slot BUILD, // building the transaction (_do_transaction_step loop) + BUILD_GET_ONODE, // onode_manager get/get_or_create calls within BUILD SUBMIT_TOTAL, // the whole submit_transaction (pipeline + journal write) // Sub-phases of submit_transaction: SUBMIT_RESERVE, // enter(reserve_projected_usage) + epm reserve_projected_usage @@ -268,6 +269,7 @@ public: std::chrono::steady_clock::time_point begin_timestamp = std::chrono::steady_clock::now(); std::chrono::steady_clock::duration build_time{0}; + std::chrono::steady_clock::duration get_onode_time{0}; std::chrono::steady_clock::duration submit_time{0}; void reset_preserve_handle(TransactionManager &tm) { From 4d0e27808f1b5d2531015deb8f7b6087e2f0d345 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Mon, 8 Jun 2026 14:10:47 -0400 Subject: [PATCH 114/596] .github/workflows/releng-audit: catch override removal error This can happen if an action is retried or the label was removed through other means (manually). Signed-off-by: Patrick Donnelly --- .github/workflows/releng-audit.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/releng-audit.yaml b/.github/workflows/releng-audit.yaml index 4a4ab014986..e9cf629d4bc 100644 --- a/.github/workflows/releng-audit.yaml +++ b/.github/workflows/releng-audit.yaml @@ -212,7 +212,11 @@ jobs: if (!isAuthorized) { core.info(`[Router] User @${actor} NOT authorized. Removing override label.`); - await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: labelName }); + try { + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: labelName }); + } catch (e) { + core.info(`[Router] Label removal skipped or already deleted: ${e.message}`); + } core.setFailed(`User @${actor} is not authorized to override audits.`); } else { core.info(`[Router] User @${actor} is authorized. Stripping fail/pass labels to visually unblock PR.`); From 830f0e562851a36abc2b450fcf0dd3e554abf24f Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Mon, 8 Jun 2026 22:47:52 +0330 Subject: [PATCH 115/596] rgw: include str_list.h for ceph::for_each_substr in CORS header get_multi_cors_method_flags() calls ceph::for_each_substr(), but rgw_cors.h did not include str_list.h. The CORS unit test includes only this header, so the build failed with an undeclared identifier. Qualify the call and add the missing include. Signed-off-by: ramin.najarbashi --- src/rgw/rgw_cors.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_cors.h b/src/rgw/rgw_cors.h index c500fab29cb..9b4fd0ee7ec 100644 --- a/src/rgw/rgw_cors.h +++ b/src/rgw/rgw_cors.h @@ -18,6 +18,7 @@ #include #include #include +#include "include/str_list.h" #define RGW_CORS_GET 0x1 #define RGW_CORS_PUT 0x2 @@ -169,7 +170,7 @@ static inline uint8_t get_multi_cors_method_flags(const char *req_meth) { else if (method == "HEAD") flags |= RGW_CORS_HEAD; else if (method == "COPY") flags |= RGW_CORS_COPY; }; - for_each_substr(allowed_methods, ";,= \t", apply_flag); + ceph::for_each_substr(allowed_methods, ";,= \t", apply_flag); return flags; } From c1478653794cd45af4589fa2813a82494df280a3 Mon Sep 17 00:00:00 2001 From: "ramin.najarbashi" Date: Mon, 8 Jun 2026 23:43:23 +0330 Subject: [PATCH 116/596] test/rgw: init CephContext in unittest_rgw_cors unittest_rgw_cors used GMock::Main without global_init, so dout() in RGWCORSRule::matches() dereferenced a null g_ceph_context and segfaulted on the first test. Link unit-main like unittest_rgw_compression. Assisted-by: Cursor:composer-2.5-fast Signed-off-by: ramin.najarbashi Co-authored-by: Cursor --- src/test/rgw/CMakeLists.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/rgw/CMakeLists.txt b/src/test/rgw/CMakeLists.txt index a61f23bd46a..7b7133f722e 100644 --- a/src/test/rgw/CMakeLists.txt +++ b/src/test/rgw/CMakeLists.txt @@ -103,7 +103,9 @@ add_ceph_unittest(unittest_rgw_bencode) target_link_libraries(unittest_rgw_bencode ${rgw_libs}) # unittest_rgw_cors -add_executable(unittest_rgw_cors test_rgw_cors.cc) +add_executable(unittest_rgw_cors + test_rgw_cors.cc + $) add_ceph_unittest(unittest_rgw_cors) target_link_libraries(unittest_rgw_cors ${rgw_libs}) From d6f1bcc89018e02e08290fda58cfa0792bd0a76a Mon Sep 17 00:00:00 2001 From: Brad Hubbard Date: Fri, 29 May 2026 13:59:41 +1000 Subject: [PATCH 117/596] common: Log when we leak and disallow inlining eaa2013 added a generic suppression that exposed the fact that the newer compiler was inlining CephContext::do_command(). In order to avoid such scenario in the future, and to keeo the behaviour constant between daemons we'll disallow inlining. Fixes: https://tracker.ceph.com/issues/76473 Signed-off-by: Brad Hubbard --- src/common/ceph_context.cc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/common/ceph_context.cc b/src/common/ceph_context.cc index afd22f86543..6c329c82457 100644 --- a/src/common/ceph_context.cc +++ b/src/common/ceph_context.cc @@ -499,7 +499,7 @@ bool CephContext::check_experimental_feature_enabled(const std::string& feat, return enabled; } -int CephContext::do_command(std::string_view command, const cmdmap_t& cmdmap, +[[gnu::noinline]] int CephContext::do_command(std::string_view command, const cmdmap_t& cmdmap, Formatter *f, std::ostream& ss, bufferlist *out) @@ -514,7 +514,8 @@ int CephContext::do_command(std::string_view command, const cmdmap_t& cmdmap, #pragma GCC push_options #pragma GCC optimize ("O0") -static void leak_some_memory() { +static void leak_some_memory(CephContext* cc) { + lgeneric_derr(cc) << "Leaking some memory" << dendl; volatile char *foo = new char[1234]; (void)foo; } @@ -538,7 +539,7 @@ int CephContext::_do_command( } } if (command == "leak_some_memory") { - leak_some_memory(); + leak_some_memory(this); } else if (command == "perfcounters_dump" || command == "1" || command == "perf dump") { From 32dbed0be14ec2ff0e90bafea17d3ccb7e39e19c Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Wed, 1 Apr 2026 15:40:16 +0530 Subject: [PATCH 118/596] mgr/cephadm: set default prometheus template in config-key store unless overridden by the user Fixes: https://tracker.ceph.com/issues/75826 Signed-off-by: Aashish Sharma --- src/pybind/mgr/cephadm/services/monitoring.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pybind/mgr/cephadm/services/monitoring.py b/src/pybind/mgr/cephadm/services/monitoring.py index 86f0aa0a8e8..0f0a85f9528 100644 --- a/src/pybind/mgr/cephadm/services/monitoring.py +++ b/src/pybind/mgr/cephadm/services/monitoring.py @@ -627,6 +627,28 @@ class PrometheusService(CephadmService): files = { 'prometheus.yml': self.mgr.template.render('services/prometheus/prometheus.yml.j2', context) } + + # check if the prometheus.yml already exists in the config-key store, + # if not we need to set the initial config-key with the default template content. + # If it already exists, we need not override user config changes. + r, outs, err = self.mgr.mon_command({ + 'prefix': 'config-key get', + 'key': 'mgr/cephadm/services/prometheus/prometheus.yml' + }) + if r == -errno.ENOENT: + loader = self.mgr.template.engine.env.loader + assert loader is not None + + raw_template, _, _ = loader.get_source( + self.mgr.template.engine.env, + 'services/prometheus/prometheus.yml.j2' + ) + self.mgr.check_mon_command({ + 'prefix': 'config-key set', + 'key': 'mgr/cephadm/services/prometheus/prometheus.yml', + 'val': raw_template + }) + r: Dict[str, Any] = { 'files': files, 'retention_time': retention_time, From a1906f997a210f6110d8e0652979006ad88e3d73 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Tue, 21 Apr 2026 13:40:54 +0530 Subject: [PATCH 119/596] mgr/cephadm: add unit tests Signed-off-by: Aashish Sharma --- .../cephadm/tests/services/test_monitoring.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py index 57a9d8a3da1..494075da155 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py +++ b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py @@ -1867,3 +1867,67 @@ spec: error_ok=True, use_current_daemon_image=False, ) + + @patch("cephadm.services.monitoring.PrometheusService.get_dependencies", lambda *a, **k: []) + def test_prometheus_config_sets_default_when_missing(self, cephadm_module): + + svc = PrometheusService(cephadm_module) + + daemon_spec = Mock() + daemon_spec.daemon_type = 'prometheus' + daemon_spec.service_name = 'prometheus' + daemon_spec.host = 'test' + + cephadm_module.mon_command = Mock(return_value=(-2, '', '')) + cephadm_module.check_mon_command = Mock() + + loader = Mock() + loader.get_source.return_value = ("raw_template_content", None, None) + cephadm_module.template.engine.env.loader = loader + + cephadm_module.template.render = Mock(return_value="rendered_config") + + cephadm_module._get_security_config = Mock(return_value=(False, False, False)) + cephadm_module._get_alertmanager_credentials = Mock(return_value=(None, None)) + cephadm_module._get_mgr_ips = Mock(return_value=['127.0.0.1']) + + cephadm_module.spec_store = { + 'prometheus': Mock() + } + cephadm_module.spec_store['prometheus'].spec = PrometheusSpec('prometheus') + + svc.generate_config(daemon_spec) + + cephadm_module.check_mon_command.assert_called_once_with({ + 'prefix': 'config-key set', + 'key': 'mgr/cephadm/services/prometheus/prometheus.yml', + 'val': "raw_template_content" + }) + + @patch("cephadm.services.monitoring.PrometheusService.get_dependencies", lambda *a, **k: []) + def test_prometheus_config_does_not_override_existing(self, cephadm_module): + + svc = PrometheusService(cephadm_module) + + daemon_spec = Mock() + daemon_spec.daemon_type = 'prometheus' + daemon_spec.service_name = 'prometheus' + daemon_spec.host = 'test' + + cephadm_module.mon_command = Mock(return_value=(0, 'existing', '')) + cephadm_module.check_mon_command = Mock() + + cephadm_module.template.render = Mock(return_value="rendered_config") + + cephadm_module._get_security_config = Mock(return_value=(False, False, False)) + cephadm_module._get_alertmanager_credentials = Mock(return_value=(None, None)) + cephadm_module._get_mgr_ips = Mock(return_value=['127.0.0.1']) + + cephadm_module.spec_store = { + 'prometheus': Mock() + } + cephadm_module.spec_store['prometheus'].spec = PrometheusSpec('prometheus') + + svc.generate_config(daemon_spec) + + cephadm_module.check_mon_command.assert_not_called() From 840ee2cfadae5c6c0f042336c1343b3876c54739 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 8 Jun 2026 16:24:40 +0800 Subject: [PATCH 120/596] crimson/osd: coroutinize OSD::start() OSD::start() is a long, deeply nested .then() continuation chain. let's rewrite it as a coroutine to make it readable. the chain was already sequential and the one concurrent step keeps its when_all_succeed(), so the rewrite preserves both ordering and concurrency. start() runs once at boot, off the i/o path, so the small overhead of co_await over a hand-rolled continuation chain is a fine price for the readability. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 268 ++++++++++++++++++++--------------------- src/crimson/osd/osd.h | 4 + 2 files changed, 132 insertions(+), 140 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 0f94d16bdfc..1404a85bfc7 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -461,6 +461,27 @@ namespace { } } +seastar::future<> OSD::report_osd_stats() +{ + LOG_PREFIX(OSD::report_osd_stats); + co_await shard_services.invoke_on_all( + [this](auto &local_service) { + auto stats = local_service.report_stats(); + shard_stats[seastar::this_shard_id()] = stats; + }); + std::ostringstream oss; + double agg_ru = 0; + int cnt = 0; + for (const auto &stats : shard_stats) { + agg_ru += stats.reactor_utilization; + ++cnt; + oss << int(stats.reactor_utilization); + oss << ","; + } + INFO("reactor_utilizations: {}({})", + int(agg_ru/cnt), oss.str()); +} + seastar::future<> OSD::start() { LOG_PREFIX(OSD::start); @@ -473,175 +494,142 @@ seastar::future<> OSD::start() startup_time = ceph::mono_clock::now(); ceph_assert(seastar::this_shard_id() == PRIMARY_CORE); DEBUG("starting store"); - return store.start().then([this] (auto store_shard_nums) { - return pg_to_shard_mappings.start(0, seastar::smp::count, store_shard_nums - ).then([this] { - return osd_singleton_state.start_single( + uint32_t store_shards_num = co_await store.start(); + co_await pg_to_shard_mappings.start(0, seastar::smp::count, store_shards_num); + co_await osd_singleton_state.start_single( whoami, std::ref(*cluster_msgr), std::ref(*public_msgr), std::ref(*monc), std::ref(*mgrc)); - }).then([this] { - return osd_states.start(); - }).then([this, store_shard_nums] { - ceph::mono_time startup_time = ceph::mono_clock::now(); - return shard_services.start( + co_await osd_states.start(); + ceph::mono_time startup_time = ceph::mono_clock::now(); + co_await shard_services.start( std::ref(osd_singleton_state), std::ref(pg_to_shard_mappings), - store_shard_nums, + store_shards_num, whoami, startup_time, osd_singleton_state.local().perf, osd_singleton_state.local().recoverystate_perf, std::ref(store), std::ref(osd_states)); - }); - }).then([this, FNAME] { - heartbeat.reset(new Heartbeat{ - whoami, get_shard_services(), - *monc, *hb_front_msgr, *hb_back_msgr}); - DEBUG("mounting store"); - return store.mount().handle_error( + heartbeat = std::make_unique( + whoami, get_shard_services(), + *monc, *hb_front_msgr, *hb_back_msgr); + DEBUG("mounting store"); + co_await store.mount().handle_error( crimson::stateful_ec::assert_failure(fmt::format( "{} error mounting object store in {}", FNAME, local_conf().get_val("osd_data")).c_str()) ); - }).then([this, FNAME] { - auto stats_seconds = local_conf().get_val("crimson_osd_stat_interval"); - if (stats_seconds > 0) { - shard_stats.resize(seastar::smp::count); - stats_timer.set_callback([this, FNAME] { - gate.dispatch_in_background("stats_osd", *this, [this, FNAME] { - return shard_services.invoke_on_all( - [this](auto &local_service) { - auto stats = local_service.report_stats(); - shard_stats[seastar::this_shard_id()] = stats; - }).then([this, FNAME] { - std::ostringstream oss; - double agg_ru = 0; - int cnt = 0; - for (const auto &stats : shard_stats) { - agg_ru += stats.reactor_utilization; - ++cnt; - oss << int(stats.reactor_utilization); - oss << ","; - } - INFO("reactor_utilizations: {}({})", - int(agg_ru/cnt), oss.str()); - }); - }); - gate.dispatch_in_background("stats_store", *this, [this] { - return store.report_stats(); - }); + auto stats_seconds = local_conf().get_val("crimson_osd_stat_interval"); + if (stats_seconds > 0) { + shard_stats.resize(seastar::smp::count); + stats_timer.set_callback([this] { + gate.dispatch_in_background("stats_osd", *this, [this] { + return report_osd_stats(); }); - stats_timer.arm_periodic(std::chrono::seconds(stats_seconds)); - } + gate.dispatch_in_background("stats_store", *this, [this] { + return store.report_stats(); + }); + }); + stats_timer.arm_periodic(std::chrono::seconds(stats_seconds)); + } - DEBUG("open metadata collection"); - return open_meta_coll(); - }).then([this, FNAME] { - DEBUG("loading superblock"); - return pg_shard_manager.get_meta_coll().load_superblock( + DEBUG("open metadata collection"); + co_await open_meta_coll(); + + DEBUG("loading superblock"); + superblock = co_await pg_shard_manager.get_meta_coll().load_superblock( ).handle_error( crimson::ct_error::assert_all("open_meta_coll error") ); - }).then([this](OSDSuperblock&& sb) { - superblock = std::move(sb); - if (!superblock.cluster_osdmap_trim_lower_bound) { - superblock.cluster_osdmap_trim_lower_bound = superblock.get_oldest_map(); - } - return pg_shard_manager.set_superblock(superblock); - }).then([this] { - return pg_shard_manager.get_local_map(superblock.current_epoch); - }).then([this](OSDMapService::local_cached_map_t&& map) { - osdmap = make_local_shared_foreign(OSDMapService::local_cached_map_t(map)); - return pg_shard_manager.update_map(std::move(map)); - }).then([this] { - return shard_services.invoke_on_all([this](auto &local_service) { - local_service.local_state.osdmap_gate.got_map(osdmap->get_epoch()); - }); - }).then([this, FNAME] { - bind_epoch = osdmap->get_epoch(); - DEBUG("loading PGs"); - return pg_shard_manager.load_pgs(store); - }).then([this, FNAME] { - uint64_t osd_required = - CEPH_FEATURE_UID | - CEPH_FEATURE_PGID64 | - CEPH_FEATURE_OSDENC; - using crimson::net::SocketPolicy; + if (!superblock.cluster_osdmap_trim_lower_bound) { + superblock.cluster_osdmap_trim_lower_bound = superblock.get_oldest_map(); + } + co_await pg_shard_manager.set_superblock(superblock); + auto local_map = co_await pg_shard_manager.get_local_map(superblock.current_epoch); + osdmap = make_local_shared_foreign(OSDMapService::local_cached_map_t(local_map)); + co_await pg_shard_manager.update_map(std::move(local_map)); + co_await shard_services.invoke_on_all([this](auto &local_service) { + local_service.local_state.osdmap_gate.got_map(osdmap->get_epoch()); + }); - public_msgr->set_default_policy(SocketPolicy::stateless_server(0)); - public_msgr->set_policy(entity_name_t::TYPE_MON, - SocketPolicy::lossy_client(osd_required)); - public_msgr->set_policy(entity_name_t::TYPE_MGR, - SocketPolicy::lossy_client(osd_required)); - public_msgr->set_policy(entity_name_t::TYPE_OSD, - SocketPolicy::stateless_server(0)); + bind_epoch = osdmap->get_epoch(); + DEBUG("loading PGs"); + co_await pg_shard_manager.load_pgs(store); - cluster_msgr->set_default_policy(SocketPolicy::stateless_server(0)); - cluster_msgr->set_policy(entity_name_t::TYPE_MON, - SocketPolicy::lossy_client(0)); - cluster_msgr->set_policy(entity_name_t::TYPE_OSD, - SocketPolicy::lossless_peer(osd_required)); - cluster_msgr->set_policy(entity_name_t::TYPE_CLIENT, - SocketPolicy::stateless_server(0)); + uint64_t osd_required = + CEPH_FEATURE_UID | + CEPH_FEATURE_PGID64 | + CEPH_FEATURE_OSDENC; + using crimson::net::SocketPolicy; - crimson::net::dispatchers_t dispatchers{this, monc.get(), mgrc.get()}; - return seastar::when_all_succeed( - cluster_msgr->bind(pick_addresses(CEPH_PICK_ADDRESS_CLUSTER)) - .safe_then([this, dispatchers]() mutable { - return cluster_msgr->start(dispatchers); - }, crimson::net::Messenger::bind_ertr::assert_all_func( - [FNAME] (const std::error_code& e) { - ERROR("cluster messenger bind(): {}", e); - })), - public_msgr->bind(pick_addresses(CEPH_PICK_ADDRESS_PUBLIC)) - .safe_then([this, dispatchers]() mutable { - return public_msgr->start(dispatchers); - }, crimson::net::Messenger::bind_ertr::assert_all_func( - [FNAME] (const std::error_code& e) { - ERROR("public messenger bind(): {}", e); - }))); - }).then_unpack([this, FNAME] { + public_msgr->set_default_policy(SocketPolicy::stateless_server(0)); + public_msgr->set_policy(entity_name_t::TYPE_MON, + SocketPolicy::lossy_client(osd_required)); + public_msgr->set_policy(entity_name_t::TYPE_MGR, + SocketPolicy::lossy_client(osd_required)); + public_msgr->set_policy(entity_name_t::TYPE_OSD, + SocketPolicy::stateless_server(0)); + + cluster_msgr->set_default_policy(SocketPolicy::stateless_server(0)); + cluster_msgr->set_policy(entity_name_t::TYPE_MON, + SocketPolicy::lossy_client(0)); + cluster_msgr->set_policy(entity_name_t::TYPE_OSD, + SocketPolicy::lossless_peer(osd_required)); + cluster_msgr->set_policy(entity_name_t::TYPE_CLIENT, + SocketPolicy::stateless_server(0)); + + crimson::net::dispatchers_t dispatchers{this, monc.get(), mgrc.get()}; + co_await seastar::when_all_succeed( + cluster_msgr->bind(pick_addresses(CEPH_PICK_ADDRESS_CLUSTER)) + .safe_then([this, dispatchers]() mutable { + return cluster_msgr->start(dispatchers); + }, crimson::net::Messenger::bind_ertr::assert_all_func( + [FNAME] (const std::error_code& e) { + ERROR("cluster messenger bind(): {}", e); + })), + public_msgr->bind(pick_addresses(CEPH_PICK_ADDRESS_PUBLIC)) + .safe_then([this, dispatchers]() mutable { + return public_msgr->start(dispatchers); + }, crimson::net::Messenger::bind_ertr::assert_all_func( + [FNAME] (const std::error_code& e) { + ERROR("public messenger bind(): {}", e); + })) + ).then_unpack([this, FNAME] { DEBUG("starting mon and mgr clients"); return seastar::when_all_succeed(monc->start(), mgrc->start()); }).then_unpack([this, FNAME] { DEBUG("adding to crush"); return _add_me_to_crush(); - }).then([this] { - return _add_device_class(); - }).then([this] { - if (is_rotational.has_value()) { - return shard_services.invoke_on_all([this](auto &local_service) { - local_service.local_state.initialize_scheduler(local_service.get_cct(), *is_rotational); - }); - } else { - throw std::runtime_error("No device class is set"); - } - }).then([this] { - monc->sub_want("osd_pg_creates", last_pg_create_epoch, 0); - monc->sub_want("mgrmap", 0, 0); - monc->sub_want("osdmap", 0, 0); - return monc->renew_subs(); - }).then([FNAME, this] { - if (auto [addrs, changed] = - replace_unknown_addrs(cluster_msgr->get_myaddrs(), - public_msgr->get_myaddrs()); changed) { - DEBUG("replacing unkwnown addrs of cluster messenger"); - cluster_msgr->set_myaddrs(addrs); - } - return heartbeat->start(pick_addresses(CEPH_PICK_ADDRESS_PUBLIC), - pick_addresses(CEPH_PICK_ADDRESS_CLUSTER)); - }).then([this] { - // create the admin-socket server, and the objects that register - // to handle incoming commands - return start_asok_admin(); - }).then([this] { - return log_client.set_fsid(monc->get_fsid()); - }).then([this, FNAME] { - DEBUG("starting boot"); - return start_boot(); }); + co_await _add_device_class(); + if (is_rotational.has_value()) { + co_await shard_services.invoke_on_all([this](auto &local_service) { + local_service.local_state.initialize_scheduler(local_service.get_cct(), *is_rotational); + }); + } else { + throw std::runtime_error("No device class is set"); + } + monc->sub_want("osd_pg_creates", last_pg_create_epoch, 0); + monc->sub_want("mgrmap", 0, 0); + monc->sub_want("osdmap", 0, 0); + co_await monc->renew_subs(); + + if (auto [addrs, changed] = + replace_unknown_addrs(cluster_msgr->get_myaddrs(), + public_msgr->get_myaddrs()); changed) { + DEBUG("replacing unkwnown addrs of cluster messenger"); + cluster_msgr->set_myaddrs(addrs); + } + co_await heartbeat->start(pick_addresses(CEPH_PICK_ADDRESS_PUBLIC), + pick_addresses(CEPH_PICK_ADDRESS_CLUSTER)); + // create the admin-socket server, and the objects that register + // to handle incoming commands + co_await start_asok_admin(); + co_await log_client.set_fsid(monc->get_fsid()); + DEBUG("starting boot"); + co_await start_boot(); } seastar::future<> OSD::start_boot() diff --git a/src/crimson/osd/osd.h b/src/crimson/osd/osd.h index 703ccd3ae5e..01b31295bcc 100644 --- a/src/crimson/osd/osd.h +++ b/src/crimson/osd/osd.h @@ -134,6 +134,10 @@ class OSD final : public crimson::net::Dispatcher, seastar::timer stats_timer; std::vector shard_stats; + // collect per-shard stats and log reactor utilization; a member coroutine + // so its captures (this) live in the frame, since it runs as a detached + // gated task a capturing lambda coroutine would dangle (see report_osd_stats). + seastar::future<> report_osd_stats(); std::vector get_tracked_keys() const noexcept final; void handle_conf_change(const ConfigProxy& conf, From acbfd2de97254f97bd098e83a94c5819adcc34d1 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 8 Jun 2026 16:54:47 +0800 Subject: [PATCH 121/596] crimson/osd: remove OSD::startup_time OSD::startup_time was added in 91c0df81, in which was added to provide a monotomic increasing timestamp representing the startup time. but later, we decided to keep track of this timestamp in PerShardState. but we didn't remove OSD::startup_time when adding PerShardState::get_mnow(). in this change, we remove the unused OSD::startup_time. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 2 +- src/crimson/osd/osd.h | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 1404a85bfc7..c7288bef612 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -491,7 +491,7 @@ seastar::future<> OSD::start() cpu_cores.empty()) { clog->info() << "for optimal performance please set crimson_cpu_set"; } - startup_time = ceph::mono_clock::now(); + ceph_assert(seastar::this_shard_id() == PRIMARY_CORE); DEBUG("starting store"); uint32_t store_shards_num = co_await store.start(); diff --git a/src/crimson/osd/osd.h b/src/crimson/osd/osd.h index 01b31295bcc..ebbaf1f6f0c 100644 --- a/src/crimson/osd/osd.h +++ b/src/crimson/osd/osd.h @@ -91,8 +91,6 @@ class OSD final : public crimson::net::Dispatcher, //< since when there is no more pending pg creates from mon epoch_t last_pg_create_epoch = 0; - ceph::mono_time startup_time; - seastar::shared_mutex handle_osd_map_lock; OSDSuperblock superblock; From 1879f2470b70f58e7d68408671faaea7b950a575 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 8 Jun 2026 17:17:26 +0800 Subject: [PATCH 122/596] crimson/osd: init PerShardState::startup_time per-shard previously, we got the mono_clock::now() in OSD::start() and passed it to PerShardState. this worked fine. but it was a little bit convoluted -- we pass the startup_time all the way to PerShardState. in this change, we just use call mono_clock::now() in the contructor of PerShardState. simpler this way. the startup_time has two consumers: - the PGs hosted by the sharded_service use it as a reference for the monotonic timestamp - Heartbeat::send_heartbeats() uses it as for the mono_ping_stamp. because, strictly speaking, we cannot gurantee that all PerShardState sharded services share the identical startup timestamp, as they are constructed on different shards. but this does not matter, as PGs always use the hosting shard service for the referencing timestamp, and OSD always uses the shard service on local shard for sending heartbeats. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 2 -- src/crimson/osd/shard_services.cc | 3 +-- src/crimson/osd/shard_services.h | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index c7288bef612..be62e7eef50 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -500,13 +500,11 @@ seastar::future<> OSD::start() whoami, std::ref(*cluster_msgr), std::ref(*public_msgr), std::ref(*monc), std::ref(*mgrc)); co_await osd_states.start(); - ceph::mono_time startup_time = ceph::mono_clock::now(); co_await shard_services.start( std::ref(osd_singleton_state), std::ref(pg_to_shard_mappings), store_shards_num, whoami, - startup_time, osd_singleton_state.local().perf, osd_singleton_state.local().recoverystate_perf, std::ref(store), diff --git a/src/crimson/osd/shard_services.cc b/src/crimson/osd/shard_services.cc index 3780cd626c6..c75717a77f6 100644 --- a/src/crimson/osd/shard_services.cc +++ b/src/crimson/osd/shard_services.cc @@ -34,7 +34,6 @@ namespace crimson::osd { PerShardState::PerShardState( int whoami, - ceph::mono_time startup_time, PerfCounters *perf, PerfCounters *recoverystate_perf, crimson::os::FuturizedStore &store, @@ -50,7 +49,7 @@ PerShardState::PerShardState( // ids generated by different shards are disjoint static_cast(seastar::this_shard_id()) << (std::numeric_limits::digits - 8)), - startup_time(startup_time), + startup_time(ceph::mono_clock::now()), ec_extent_cache_lru(crimson::common::local_conf().get_val("ec_extent_cache_size")) {} diff --git a/src/crimson/osd/shard_services.h b/src/crimson/osd/shard_services.h index 19717ee55ef..3f0e28c7942 100644 --- a/src/crimson/osd/shard_services.h +++ b/src/crimson/osd/shard_services.h @@ -217,7 +217,6 @@ class PerShardState { public: PerShardState( int whoami, - ceph::mono_time startup_time, PerfCounters *perf, PerfCounters *recoverystate_perf, crimson::os::FuturizedStore &store, From 91878740bbe9939b996df99861bf74ada3382050 Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Tue, 9 Jun 2026 10:31:12 +0530 Subject: [PATCH 123/596] crimson/cbt: use yaml.safe_load in t2c and add unit tests t2c.py translates teuthology benchmark YAML into CBT configuration for run-cbt.sh. Switch input parsing from yaml.load() to yaml.safe_load() so the module works with current PyYAML and avoids unsafe deserialization. Add test_t2c.py covering get_cbt_tasks(), Translator.translate(), and main(), and register it with make check via add_ceph_test. Document the translator in doc/dev/crimson/index.rst and describe the ceph-perf-pull-requests Jenkins workflow in doc/dev/continuous-integration.rst. Signed-off-by: Kautilya Tripathi --- doc/dev/continuous-integration.rst | 27 ++++ doc/dev/crimson/index.rst | 12 +- src/test/crimson/CMakeLists.txt | 2 + src/test/crimson/cbt/CMakeLists.txt | 2 + src/test/crimson/cbt/t2c.py | 20 ++- src/test/crimson/cbt/test_t2c.py | 187 ++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/test/crimson/cbt/CMakeLists.txt create mode 100644 src/test/crimson/cbt/test_t2c.py diff --git a/doc/dev/continuous-integration.rst b/doc/dev/continuous-integration.rst index 5c2f158236c..091f4f154a4 100644 --- a/doc/dev/continuous-integration.rst +++ b/doc/dev/continuous-integration.rst @@ -94,6 +94,33 @@ Shaman for its `Web UI`_. But please note, shaman does not build the packages, it just offers information on the builds. +ceph-perf-pull-requests +----------------------- + +``ceph-perf-pull-requests`` runs CBT performance regression checks on dedicated +``performance`` Jenkins agents. The job definition lives in `ceph-build`_ and +generates two jobs from a single template: ``ceph-perf-classic`` and +``ceph-perf-crimson``. + +A pull request can trigger a run with:: + + jenkins test classic perf + jenkins test crimson perf + +The ``performance`` label is also whitelisted for automatic runs. Each job: + +#. checks out ``ceph-main`` (``origin/main``) and the PR merge ref +#. builds both trees (classic ``vstart-base`` or Crimson ``crimson-osd``) +#. runs the checked-in ``radosbench_4K_read.yaml`` workload via ``run-cbt.sh`` +#. compares PR results against ``main`` with ``cbt/compare.py`` +#. publishes a GitHub check named ``perf-test-{classic,crimson}`` + +The benchmark YAML is always taken from ``ceph-main`` so PR and baseline runs use +the same workload definition. Teuthology-to-CBT translation is handled by +``src/test/crimson/cbt/t2c.py`` in the Ceph tree (not patched at build time). + +.. _ceph-build: https://github.com/ceph/ceph-build + As the following shows, `chacra`_ manages multiple projects whose metadata are stored in a database. These metadata are exposed via Shaman as a web service. `chacractl`_ is a utility to interact with the `chacra`_ service. diff --git a/doc/dev/crimson/index.rst b/doc/dev/crimson/index.rst index fc7ec7fa3ff..1ee5afd6fb9 100644 --- a/doc/dev/crimson/index.rst +++ b/doc/dev/crimson/index.rst @@ -253,7 +253,17 @@ In order to use ``fio`` to test ``crimson-store-nbd``, perform the below steps. CBT --- -We can use `cbt`_ for performance tests:: +We can use `cbt`_ for performance tests. Benchmark workloads are checked in under +``src/test/crimson/cbt/`` as teuthology-style YAML files. Before ``run-cbt.sh`` +invokes CBT, ``t2c.py`` translates the teuthology ``tasks`` list into a +CBT-ready configuration: it extracts the ``cbt`` task, fills in cluster paths +(``ceph.conf``, ``ceph``/``rados`` binaries, PID directory), and writes the +result to a temporary YAML file consumed by ``cbt.py``. + +Unit tests for the translator live in ``src/test/crimson/cbt/test_t2c.py`` and +are registered with ``make check`` via ``add_ceph_test``. + +:: $ git checkout main $ make crimson-osd diff --git a/src/test/crimson/CMakeLists.txt b/src/test/crimson/CMakeLists.txt index 8a8c82ae5e7..71e0b876f67 100644 --- a/src/test/crimson/CMakeLists.txt +++ b/src/test/crimson/CMakeLists.txt @@ -135,3 +135,5 @@ target_link_libraries( unittest-crimson-scrub crimson-common crimson::gtest) + +add_subdirectory(cbt) diff --git a/src/test/crimson/cbt/CMakeLists.txt b/src/test/crimson/cbt/CMakeLists.txt new file mode 100644 index 00000000000..8a969b684bd --- /dev/null +++ b/src/test/crimson/cbt/CMakeLists.txt @@ -0,0 +1,2 @@ +add_ceph_test(test_t2c.py + ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_t2c.py) diff --git a/src/test/crimson/cbt/t2c.py b/src/test/crimson/cbt/t2c.py index 0d4ee49e5b6..9a13eadd3f2 100755 --- a/src/test/crimson/cbt/t2c.py +++ b/src/test/crimson/cbt/t2c.py @@ -1,4 +1,12 @@ #!/usr/bin/env python3 +""" +Translate a teuthology-style benchmark YAML into a CBT configuration file. + +The YAML files under ``src/test/crimson/cbt/`` describe workloads using +teuthology's ``tasks`` list. ``run-cbt.sh`` invokes this module to extract the +``cbt`` task and emit a CBT-ready configuration (cluster layout, benchmark +definitions, monitoring profiles). +""" from __future__ import print_function import argparse @@ -39,12 +47,16 @@ class Translator(object): rados_cmd=os.path.join(self.build_dir, 'bin', 'rados'), pid_dir=os.path.join(self.build_dir, 'out') )) - return conf + return conf def get_cbt_tasks(path): - with open(path) as input: - teuthology_config = yaml.load(input) - for task in teuthology_config['tasks']: + with open(path) as yaml_file: + teuthology_config = yaml.safe_load(yaml_file) or {} + if not isinstance(teuthology_config, dict): + teuthology_config = {} + for task in teuthology_config.get('tasks', []): + if not isinstance(task, dict): + continue for name, conf in task.items(): if name == 'cbt': yield conf diff --git a/src/test/crimson/cbt/test_t2c.py b/src/test/crimson/cbt/test_t2c.py new file mode 100644 index 00000000000..02c39c1bce1 --- /dev/null +++ b/src/test/crimson/cbt/test_t2c.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +import os +import sys +import tempfile +import unittest +import unittest.mock + +import yaml + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import t2c # noqa: E402 + + +SAMPLE_TEUTHOLOGY_YAML = """\ +meta: +- desc: sample radosbench workload +tasks: +- install: + extra_system_packages: + deb: + - lvm2 +- cbt: + benchmarks: + radosbench: + read_time: 30 + read_only: true + monitoring_profiles: + perf: + nodes: + - osds + cluster: + osds_per_node: 3 + iterations: 1 + pool_profiles: + replicated: + pg_size: 128 + pgp_size: 128 + replication: replicated +""" + + +class TestGetCbtTasks(unittest.TestCase): + def _write_yaml(self, contents): + handle = tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', + delete=False) + handle.write(contents) + handle.close() + self.addCleanup(os.unlink, handle.name) + return handle.name + + def test_extracts_cbt_task(self): + path = self._write_yaml(SAMPLE_TEUTHOLOGY_YAML) + tasks = list(t2c.get_cbt_tasks(path)) + self.assertEqual(len(tasks), 1) + self.assertIn('benchmarks', tasks[0]) + self.assertEqual(tasks[0]['benchmarks']['radosbench']['read_time'], 30) + self.assertEqual(tasks[0]['cluster']['osds_per_node'], 3) + + def test_ignores_non_cbt_tasks(self): + path = self._write_yaml("tasks:\n- install:\n version: main\n") + tasks = list(t2c.get_cbt_tasks(path)) + self.assertEqual(tasks, []) + + def test_empty_file_returns_no_tasks(self): + path = self._write_yaml("") + tasks = list(t2c.get_cbt_tasks(path)) + self.assertEqual(tasks, []) + + def test_missing_tasks_key_returns_no_tasks(self): + path = self._write_yaml("meta:\n- desc: no tasks here\n") + tasks = list(t2c.get_cbt_tasks(path)) + self.assertEqual(tasks, []) + + def test_multiple_cbt_tasks(self): + path = self._write_yaml( + "tasks:\n" + "- cbt:\n" + " cluster:\n" + " iterations: 1\n" + "- cbt:\n" + " cluster:\n" + " iterations: 2\n") + tasks = list(t2c.get_cbt_tasks(path)) + self.assertEqual(len(tasks), 2) + self.assertEqual(tasks[0]['cluster']['iterations'], 1) + self.assertEqual(tasks[1]['cluster']['iterations'], 2) + + +class TestTranslator(unittest.TestCase): + def test_translate_builds_cbt_cluster_section(self): + build_dir = '/tmp/ceph-build' + translator = t2c.Translator(build_dir) + cbt_task = { + 'cluster': { + 'osds_per_node': 4, + 'iterations': 2, + 'pool_profiles': { + 'replicated': { + 'pg_size': 64, + 'pgp_size': 64, + 'replication': 'replicated', + }, + }, + }, + 'benchmarks': { + 'radosbench': { + 'read_time': 10, + }, + }, + 'monitoring_profiles': { + 'perf': { + 'nodes': ['osds'], + }, + }, + } + + translated = translator.translate(cbt_task) + cluster = translated['cluster'] + self.assertEqual(cluster['osds_per_node'], 4) + self.assertEqual(cluster['iterations'], 2) + self.assertEqual(cluster['pool_profiles'], cbt_task['cluster']['pool_profiles']) + self.assertEqual(cluster['conf_file'], os.path.join(build_dir, 'ceph.conf')) + self.assertEqual(cluster['ceph_cmd'], os.path.join(build_dir, 'bin', 'ceph')) + self.assertEqual(cluster['rados_cmd'], os.path.join(build_dir, 'bin', 'rados')) + self.assertEqual(cluster['pid_dir'], os.path.join(build_dir, 'out')) + self.assertFalse(cluster['rebuild_every_test']) + self.assertEqual(translated['benchmarks'], cbt_task['benchmarks']) + self.assertEqual(translated['monitoring_profiles'], + cbt_task['monitoring_profiles']) + + +class TestMain(unittest.TestCase): + def test_main_writes_translated_yaml(self): + input_path = tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', + delete=False) + input_path.write(SAMPLE_TEUTHOLOGY_YAML) + input_path.close() + self.addCleanup(os.unlink, input_path.name) + + output_path = tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', + delete=False) + output_path.close() + self.addCleanup(os.unlink, output_path.name) + + build_dir = '/tmp/ceph-build' + argv = [ + 't2c.py', + '--build-dir', build_dir, + '--input', input_path.name, + '--output', output_path.name, + ] + with unittest.mock.patch.object(sys, 'argv', argv): + t2c.main() + + with open(output_path.name) as output: + translated = yaml.safe_load(output) + self.assertIn('cluster', translated) + self.assertIn('benchmarks', translated) + self.assertEqual(translated['cluster']['conf_file'], + os.path.join(build_dir, 'ceph.conf')) + + def test_main_errors_when_cbt_task_missing(self): + input_path = tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', + delete=False) + input_path.write("tasks:\n- install:\n") + input_path.close() + self.addCleanup(os.unlink, input_path.name) + + output_path = tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', + delete=False) + output_path.close() + self.addCleanup(os.unlink, output_path.name) + + argv = [ + 't2c.py', + '--input', input_path.name, + '--output', output_path.name, + ] + with unittest.mock.patch.object(sys, 'argv', argv): + with self.assertRaises(SystemExit) as ctx: + t2c.main() + self.assertEqual(ctx.exception.code, 1) + + +if __name__ == '__main__': + unittest.main() From 6dadd2f0309c0c016225a6891e814388d75aecd5 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Tue, 9 Jun 2026 13:33:07 +0530 Subject: [PATCH 124/596] mgr/dashboard: Remove global RGW tenant Roles tab and decommission routes Fixes: https://tracker.ceph.com/issues/77262 Signed-off-by: Sagar Gopale --- .../rgw-user-tabs.component.html | 9 +--- .../frontend/src/app/ceph/rgw/rgw.module.ts | 46 ++----------------- .../src/app/core/context/context.component.ts | 3 -- 3 files changed, 4 insertions(+), 54 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.html index 92069bb5785..bca61be2c95 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.html @@ -15,12 +15,5 @@ [routerLinkActiveOptions]="{exact: true}" i18n>Accounts - + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 510248cd9d8..c573b617bf0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -12,7 +12,7 @@ import { import { PipesModule } from '~/app/shared/pipes/pipes.module'; import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants'; -import { CRUDTableComponent } from '~/app/shared/datatable/crud-table/crud-table.component'; + import { FeatureTogglesGuardService } from '~/app/shared/services/feature-toggles-guard.service'; import { ModuleStatusGuardService } from '~/app/shared/services/module-status-guard.service'; @@ -37,7 +37,7 @@ import { DataTableModule } from '~/app/shared/datatable/datatable.module'; import { RgwMultisiteRealmFormComponent } from './rgw-multisite-realm-form/rgw-multisite-realm-form.component'; import { RgwMultisiteZonegroupFormComponent } from './rgw-multisite-zonegroup-form/rgw-multisite-zonegroup-form.component'; import { RgwMultisiteZoneFormComponent } from './rgw-multisite-zone-form/rgw-multisite-zone-form.component'; -import { CrudFormComponent } from '~/app/shared/forms/crud-form/crud-form.component'; + import { RgwMultisiteZoneDeletionFormComponent } from './models/rgw-multisite-zone-deletion-form/rgw-multisite-zone-deletion-form.component'; import { RgwMultisiteZonegroupDeletionFormComponent } from './models/rgw-multisite-zonegroup-deletion-form/rgw-multisite-zonegroup-deletion-form.component'; import { RgwSystemUserComponent } from './rgw-system-user/rgw-system-user.component'; @@ -292,47 +292,7 @@ const routes: Routes = [ } ] }, - { - path: 'roles', - data: { - breadcrumbs: 'Roles', - resource: 'api.rgw.roles@1.0', - tabs: [ - { - name: 'Users', - url: '/rgw/user' - }, - { - name: 'Accounts', - url: '/rgw/accounts' - }, - { - name: 'Roles', - url: '/rgw/roles' - } - ] - }, - children: [ - { - path: '', - component: CRUDTableComponent - }, - { - path: URLVerbs.CREATE, - component: CrudFormComponent, - data: { - breadcrumbs: ActionLabels.CREATE - } - }, - { - path: URLVerbs.EDIT, - component: CrudFormComponent, - data: { - breadcrumbs: ActionLabels.EDIT - } - } - ] - }, + { path: 'bucket', data: { breadcrumbs: 'Buckets' }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts index e652a923d7c..46e293d8d85 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts @@ -25,7 +25,6 @@ export class ContextComponent implements OnInit, OnDestroy { private subs = new Subscription(); private rgwUrlPrefix = '/rgw'; private rgwUserUrlPrefix = '/rgw/user'; - private rgwRoleUrlPrefix = '/rgw/roles'; private rgwBuckerUrlPrefix = '/rgw/bucket'; private rgwAccountsUrlPrefix = '/rgw/accounts'; private rgwMultisiteSyncPolicyPrefix = '/rgw/multisite/sync-policy'; @@ -34,7 +33,6 @@ export class ContextComponent implements OnInit, OnDestroy { isRgwRoute = document.location.href.includes(this.rgwUserUrlPrefix) || document.location.href.includes(this.rgwBuckerUrlPrefix) || - document.location.href.includes(this.rgwRoleUrlPrefix) || document.location.href.includes(this.rgwAccountsUrlPrefix) || document.location.href.includes(this.rgwMultisiteSyncPolicyPrefix); @@ -58,7 +56,6 @@ export class ContextComponent implements OnInit, OnDestroy { (this.isRgwRoute = [ this.rgwBuckerUrlPrefix, this.rgwUserUrlPrefix, - this.rgwRoleUrlPrefix, this.rgwAccountsUrlPrefix, this.rgwMultisiteSyncPolicyPrefix ].some((urlPrefix) => this.router.url.startsWith(urlPrefix))) From 8f3a16656982dfdfd55a6229c90884de365036e1 Mon Sep 17 00:00:00 2001 From: Shai Fultheim Date: Thu, 28 May 2026 10:24:05 +0300 Subject: [PATCH 125/596] crimson/os/seastore: wake blocked IO on BackgroundProcess wakeup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackgroundProcess::run() blocks on blocking_background when background_should_run() returns false. When the loop wakes up — typically because arm_blocking_io_and_wake() kicked it — the loop re-checks background_should_run(). If that still returns false (e.g. the cleaner has no pending work), the loop goes straight back to sleep. But the wake may also have been triggered by a change in the space- availability condition (should_block_io) — and there is no path that re-evaluates blocked user IO when background_should_run is false. The blocked IO has no future trigger to re-check, and stays stuck until the next unrelated wake-up happens to come at the right moment, or never. Add a maybe_wake_blocked_io() call right after the wake. It's a no-op when nothing is blocked, and unblocks IO that the space condition has already cleared otherwise. Observed in sustained random-write benches that wedged with IO blocked indefinitely while the cleaner sat idle (should_run=false). Signed-off-by: Shai Fultheim --- src/crimson/os/seastore/extent_placement_manager.cc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/crimson/os/seastore/extent_placement_manager.cc b/src/crimson/os/seastore/extent_placement_manager.cc index 024dce94028..c6110a28de3 100644 --- a/src/crimson/os/seastore/extent_placement_manager.cc +++ b/src/crimson/os/seastore/extent_placement_manager.cc @@ -841,6 +841,12 @@ ExtentPlacementManager::BackgroundProcess::run() assert(!blocking_background); blocking_background = seastar::promise<>(); co_await blocking_background->get_future(); + // After waking (typically because arm_blocking_io_and_wake() kicked us), + // give any blocked user IO a chance to proceed. Without this call the + // loop would go straight back to sleep if background_should_run() is + // still false, but the space condition (should_block_io) may already be + // satisfied, leaving blocked IO stuck with no future trigger to re-check. + maybe_wake_blocked_io(); } } log_state("run(exit)"); From 83541fd93c92bc8e93dd72ecc8467d59e15c36e5 Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:15:29 +0100 Subject: [PATCH 126/596] doc/security: Add Security Lead and Working Group pages to toctree Both securitylead.rst and workinggroup.rst existed in the tree but were not referenced in the security/index.rst toctree, making them invisible in the built documentation. Add RST document titles and include both pages in the toctree. Fixes: https://tracker.ceph.com/issues/77206 Signed-off-by: Emmanuel Ameh --- doc/security/index.rst | 2 ++ doc/security/securitylead.rst | 4 ++++ doc/security/workinggroup.rst | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/doc/security/index.rst b/doc/security/index.rst index da767680ba1..9dadb4fe226 100644 --- a/doc/security/index.rst +++ b/doc/security/index.rst @@ -7,6 +7,8 @@ Past Vulnerabilities / CVEs Vulnerability Management Process + Security Lead + Security Working Group Reporting a vulnerability ========================= diff --git a/doc/security/securitylead.rst b/doc/security/securitylead.rst index 1433e799033..fc031750436 100644 --- a/doc/security/securitylead.rst +++ b/doc/security/securitylead.rst @@ -1,3 +1,7 @@ +================ + Security Lead +================ + The CSC designates a member as Security Lead, with responsibility for co-ordinating security posture. The Security Lead also keeps the CSC updated about vulnerabilities within Ceph and progress toward addressing them. The lead diff --git a/doc/security/workinggroup.rst b/doc/security/workinggroup.rst index 8dcbc656297..3493d2fd4ab 100644 --- a/doc/security/workinggroup.rst +++ b/doc/security/workinggroup.rst @@ -1,3 +1,7 @@ +========================== + Security Working Group +========================== + In order to fully support Ceph, the security working group co-ordinates security improvements. This is essential as industry focuses more on security, and Ceph has become a mature software From 24f96c09f4f95efe90a0969228334ad842aae4de Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:17:55 +0100 Subject: [PATCH 127/596] doc: Remove empty README.md and relocate nvmeof HA design doc doc/README.md was a 0-byte file with no content; remove it. doc/nvmeof/ha.md described the internal NVMeofGwMon/NVMeofGwMap HA implementation (Paxos messaging, ANA state machines, blocklist logic) -- a developer design document, not user-facing content. User-facing NVMe-oF documentation already exists in doc/rbd/. Move to doc/dev/nvmeof-ha-design.md where developer design docs live. Fixes: https://tracker.ceph.com/issues/77208 Signed-off-by: Emmanuel Ameh --- doc/README.md | 0 doc/{nvmeof/ha.md => dev/nvmeof-ha-design.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 doc/README.md rename doc/{nvmeof/ha.md => dev/nvmeof-ha-design.md} (100%) diff --git a/doc/README.md b/doc/README.md deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/doc/nvmeof/ha.md b/doc/dev/nvmeof-ha-design.md similarity index 100% rename from doc/nvmeof/ha.md rename to doc/dev/nvmeof-ha-design.md From 1bb67ee3949de6213d6c0a59b70141a378a5bc1c Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Fri, 5 Jun 2026 09:57:00 -0400 Subject: [PATCH 128/596] librados: print nspace only if present Signed-off-by: Patrick Donnelly --- src/librados/IoCtxImpl.cc | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/librados/IoCtxImpl.cc b/src/librados/IoCtxImpl.cc index 7e3dddd1cd6..4d48400d716 100644 --- a/src/librados/IoCtxImpl.cc +++ b/src/librados/IoCtxImpl.cc @@ -666,8 +666,12 @@ int librados::IoCtxImpl::operate(const object_t& oid, ::ObjectOperation *o, Context *oncommit = new C_SafeCond(mylock, cond, &done, &r); int op = o->ops[0].op.op; - ldout(client->cct, 10) << ceph_osd_op_name(op) << " oid=" << oid - << " nspace=" << oloc.nspace << dendl; + ldout(client->cct, 10) << ceph_osd_op_name(op) << " oid=" << oid; + if (!oloc.nspace.empty()) { + *_dout << " nspace=" << oloc.nspace; + } + *_dout << dendl; + Objecter::Op *objecter_op = objecter->prepare_mutate_op( oid, oloc, *o, snapc, ut, @@ -720,7 +724,11 @@ int librados::IoCtxImpl::operate_read(const object_t& oid, Context *onack = new C_SafeCond(mylock, cond, &done, &r); int op = o->ops[0].op.op; - ldout(client->cct, 10) << ceph_osd_op_name(op) << " oid=" << oid << " nspace=" << oloc.nspace << dendl; + ldout(client->cct, 10) << ceph_osd_op_name(op) << " oid=" << oid; + if (!oloc.nspace.empty()) { + *_dout << " nspace=" << oloc.nspace; + } + *_dout << dendl; Objecter::Op *objecter_op = objecter->prepare_read_op( oid, oloc, *o, snap_seq, pbl, From 04aceefad0ef2b0186b0854ae28b4fbc4ffa51c3 Mon Sep 17 00:00:00 2001 From: Shilpa Jagannath Date: Fri, 5 Jun 2026 14:42:35 -0400 Subject: [PATCH 129/596] common/async: async_cond::notify/cancel must post to handler's associated executor RGWDeleteMultiObj spawns child coroutines via spawn_throttle to delete objects in parallel. each child coroutine carries the connection strand as its associated executor, serializing concurrent operations on shared state like the response formatter and ops_log_entries. but in multisite env, concurrent deletions in the same bucket shard contend on async_cond in RGWDataChangesLog::add_entry(). when notify() fires, waiting coroutines resume on the raw io_context executor instead of their connection strand, breaking the serialization that prevents data races in send_partial_response() any_completion_handler doesn't support post(). so post() to the default executor and dispatch to the associated executor from there Signed-off-by: Shilpa Jagannath --- src/common/async/async_cond.h | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/common/async/async_cond.h b/src/common/async/async_cond.h index 0ad87f75cd0..53869910b44 100644 --- a/src/common/async/async_cond.h +++ b/src/common/async/async_cond.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -58,8 +59,8 @@ class async_cond : public service_list_base_hook { std::mutex m; std::vector, std::unique_lock*>> handlers; + boost::asio::any_completion_handler, + std::unique_lock*>> handlers; void service_shutdown() { std::unique_lock l(m); @@ -135,12 +136,13 @@ public: handlers.resize(0); l.unlock(); for (auto&& [handler, lock] : workhandlers) { + auto ex = asio::get_associated_executor(handler, executor); asio::post(executor, - [handler = std::move(handler), lock = lock]() mutable { - lock->lock(); - std::move(handler)(sys::error_code{}); - }); - + asio::bind_executor(ex, + [handler = std::move(handler), lock = lock]() mutable { + lock->lock(); + std::move(handler)(sys::error_code{}); + })); } } } @@ -157,12 +159,13 @@ public: handlers.resize(0); l.unlock(); for (auto&& [handler, lock] : workhandlers) { + auto ex = asio::get_associated_executor(handler, executor); asio::post(executor, - [handler = std::move(handler), lock = lock]() mutable { - lock->lock(); - std::move(handler)(asio::error::operation_aborted); - }); - + asio::bind_executor(ex, + [handler = std::move(handler), lock = lock]() mutable { + lock->lock(); + std::move(handler)(asio::error::operation_aborted); + })); } } } From 43dfdc8fdbfe97e0b8e2020e6658bcfafe3f4877 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Tue, 21 Apr 2026 17:17:33 -0700 Subject: [PATCH 130/596] mgr: Allow autoscaling for overlapped roots Remove references to overlapped roots. Assign PG budget per root as sum(mon_target_pg_per_osd/# roots that contain OSD) Fixes: https://tracker.ceph.com/issues/73892 Signed-off-by: Eric Zhang --- src/pybind/mgr/pg_autoscaler/module.py | 105 ++++++++++--------------- 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/src/pybind/mgr/pg_autoscaler/module.py b/src/pybind/mgr/pg_autoscaler/module.py index 9b9050a049a..f6e015ca3d1 100644 --- a/src/pybind/mgr/pg_autoscaler/module.py +++ b/src/pybind/mgr/pg_autoscaler/module.py @@ -19,8 +19,6 @@ Some terminology is made up for the purposes of this module: - "raw pgs": pg count after applying replication, i.e. the real resource consumption of a pool. - "grow/shrink" - increase/decrease the pg_num in a pool - - "crush subtree" - non-overlapping domains in crush hierarchy: used as - units of resource management. """ INTERVAL = 5 @@ -113,12 +111,12 @@ class PgAdjustmentProgress(object): refs=[("pool", self.pool_id)]) -class CrushSubtreeResourceStatus: +class CrushRootResourceStatus: def __init__(self) -> None: - self.root_ids: List[int] = [] + self.root_id: int self.osds: Set[int] = set() self.osd_count: Optional[int] = None # Number of OSDs - self.pg_target: Optional[int] = None # Ideal full-capacity PG count? + self.pg_target = 0 # Ideal full-capacity PG count? self.pg_current = 0 # How many PGs already? self.pg_left = 0 self.capacity: Optional[int] = None # Total capacity of OSDs in subtree @@ -425,17 +423,18 @@ class PgAutoscaler(MgrModule): self.log.info('Stopping pg_autoscaler') self._shutdown.set() - def identify_subtrees_and_overlaps(self, - osdmap: OSDMap, - pools: Dict[str, Dict[str, Any]], - crush: CRUSHMap, - result: Dict[int, CrushSubtreeResourceStatus], - overlapped_roots: Set[int], - roots: List[CrushSubtreeResourceStatus]) -> \ - Tuple[List[CrushSubtreeResourceStatus], - Set[int]]: + def identify_subtrees(self, + osdmap: OSDMap, + pools: Dict[str, Dict[str, Any]], + crush: CRUSHMap, + result: Dict[int, CrushRootResourceStatus], + roots: List[CrushRootResourceStatus], + osd_uses_by_root: Dict[int, Set[int]]) -> \ + Tuple[List[CrushRootResourceStatus], + Dict[int, Set[int]]]: + + # We identify subtrees from osdmap - # We identify subtrees and overlapping roots from osdmap for pool_name, pool in pools.items(): crush_rule = crush.get_rule_by_id(pool['crush_rule']) assert crush_rule is not None @@ -443,26 +442,15 @@ class PgAutoscaler(MgrModule): root_id = crush.get_rule_root(cr_name) assert root_id is not None osds = set(crush.get_osds_under(root_id)) - - # Are there overlapping roots? + for osd in osds: + osd_uses_by_root[osd].add(root_id) s = None - for prev_root_id, prev in result.items(): - if osds & prev.osds: - s = prev - if prev_root_id != root_id: - overlapped_roots.add(prev_root_id) - overlapped_roots.add(root_id) - self.log.warning("pool %s won't scale due to overlapping roots: %s", - pool_name, overlapped_roots) - self.log.warning("Please See: https://docs.ceph.com/en/" - "latest/rados/operations/placement-groups" - "/#automated-scaling") - break - if not s: - s = CrushSubtreeResourceStatus() + if root_id not in result: + s = CrushRootResourceStatus() roots.append(s) - result[root_id] = s - s.root_ids.append(root_id) + result[root_id] = s + s.root_id = root_id + s = result[root_id] s.osds |= osds s.pool_ids.append(pool['pool']) s.pool_names.append(pool_name) @@ -474,32 +462,33 @@ class PgAutoscaler(MgrModule): target_bytes = pool['options'].get('target_size_bytes', 0) if target_bytes: s.total_target_bytes += target_bytes * osdmap.pool_raw_used_rate(pool['pool']) - return roots, overlapped_roots + return roots, osd_uses_by_root def get_subtree_resource_status(self, osdmap: OSDMap, pools: Dict[str, Dict[str, Any]], - crush: CRUSHMap) -> Tuple[Dict[int, CrushSubtreeResourceStatus], - Set[int]]: + crush: CRUSHMap) -> Dict[int, CrushRootResourceStatus]: """ For each CRUSH subtree of interest (i.e. the roots under which we have pools), calculate the current resource usages and targets, such as how many PGs there are, vs. how many PGs we would like there to be. """ - result: Dict[int, CrushSubtreeResourceStatus] = {} - roots: List[CrushSubtreeResourceStatus] = [] - overlapped_roots: Set[int] = set() - # identify subtrees and overlapping roots - roots, overlapped_roots = self.identify_subtrees_and_overlaps( - osdmap, pools, crush, result, overlapped_roots, roots + result: Dict[int, CrushRootResourceStatus] = {} + roots: List[CrushRootResourceStatus] = [] + osd_uses_by_root: Dict[int, Set[int]] = defaultdict(set[int]) + # identify subtrees and roots + roots, osd_uses_by_root = self.identify_subtrees( + osdmap, pools, crush, result, roots, osd_uses_by_root ) # finish subtrees all_stats = self.get('osd_stats') + for osd, root_ids in osd_uses_by_root.items(): + for root_id in root_ids: + result[root_id].pg_target += self.mon_target_pg_per_osd // len(root_ids) for s in roots: assert s.osds is not None s.osd_count = len(s.osds) - s.pg_target = s.osd_count * self.mon_target_pg_per_osd s.pg_left = s.pg_target s.pool_count = len(s.pool_ids) capacity = 0 @@ -512,13 +501,12 @@ class PgAutoscaler(MgrModule): capacity += osd_stats['kb'] * 1024 s.capacity = capacity - self.log.debug('root_ids %s pools %s with %d osds, pg_target %d', - s.root_ids, + self.log.debug('root_id %s pools %s with %d osds, pg_target %d', + s.root_id, s.pool_ids, s.osd_count, s.pg_target) - - return result, overlapped_roots + return result def _append_result ( self, @@ -568,7 +556,7 @@ class PgAutoscaler(MgrModule): def _find_optimal_pg_distribution ( self, - root_map: Dict[int, CrushSubtreeResourceStatus], + root_map: Dict[int, CrushRootResourceStatus], root_id: int, base: int, cost: int, @@ -617,7 +605,7 @@ class PgAutoscaler(MgrModule): def _calculate_final_pool_pg_target( self, - root_map: Dict[int, CrushSubtreeResourceStatus], + root_map: Dict[int, CrushRootResourceStatus], root_id: int, func_pass: 'PassT', pool_group: Dict[GroupKey, PoolGroup], @@ -736,7 +724,7 @@ class PgAutoscaler(MgrModule): def _calculate_pool_metrics( self, osdmap: OSDMap, - root_map: Dict[int, CrushSubtreeResourceStatus], + root_map: Dict[int, CrushRootResourceStatus], root_id: int, pool_id: int, pool_stats: Dict[int, Dict[str, int]], @@ -781,7 +769,7 @@ class PgAutoscaler(MgrModule): def _get_pool_pg_targets( self, - root_map: Dict[int, CrushSubtreeResourceStatus], + root_map: Dict[int, CrushRootResourceStatus], ret: List[Dict[str, Any]], threshold: float, func_pass: 'PassT', @@ -879,9 +867,8 @@ class PgAutoscaler(MgrModule): osdmap: OSDMap, pools: Dict[str, Dict[str, Any]], crush_map: CRUSHMap, - root_map: Dict[int, CrushSubtreeResourceStatus], + root_map: Dict[int, CrushRootResourceStatus], pool_stats: Dict[int, Dict[str, int]], - overlapped_roots: Set[int], pool_groups_by_root: Dict[int, Dict[GroupKey, PoolGroup]], pool_metrics: Dict[str, Dict[str, Any]], ) -> None: @@ -901,12 +888,6 @@ class PgAutoscaler(MgrModule): cr_name = crush_rule['rule_name'] root_id = crush_map.get_rule_root(cr_name) assert root_id is not None - if root_id in overlapped_roots: - # skip pools - # with overlapping roots - self.log.warn("pool %d contains an overlapping root %d" - "... skipping scaling", pool_id, root_id) - continue capacity = root_map[root_id].capacity assert capacity is not None if capacity == 0: @@ -944,12 +925,12 @@ class PgAutoscaler(MgrModule): osdmap: OSDMap, pools: Dict[str, Dict[str, Any]], ) -> Tuple[List[Dict[str, Any]], - Dict[int, CrushSubtreeResourceStatus]]: + Dict[int, CrushRootResourceStatus]]: threshold = self.threshold assert threshold >= 1.0 crush_map = osdmap.get_crush() - root_map, overlapped_roots = self.get_subtree_resource_status(osdmap, pools, crush_map) + root_map = self.get_subtree_resource_status(osdmap, pools, crush_map) df = self.get('df') pool_stats = dict([(p['id'], p['stats']) for p in df['pools']]) @@ -977,7 +958,7 @@ class PgAutoscaler(MgrModule): # 'target_ratio': float # 'bulk': bool # } - self._compute_pool_group_metrics(osdmap, pools, crush_map, root_map, pool_stats, overlapped_roots, pool_groups_by_root, pool_metrics) + self._compute_pool_group_metrics(osdmap, pools, crush_map, root_map, pool_stats, pool_groups_by_root, pool_metrics) ret = self._get_pool_pg_targets(root_map, ret, threshold, 'first', pool_groups_by_root, pool_metrics) ret = self._get_pool_pg_targets(root_map, ret, threshold, 'second', pool_groups_by_root, pool_metrics) From 47c8ab5cacf340638560d0f04bf8a0bf2b885ec1 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Tue, 21 Apr 2026 17:20:43 -0700 Subject: [PATCH 131/596] mgr: Modify test_overlapping_roots for PG budget No longer need to detect which roots are overlapping. Instead test PG budget allocation when roots overlap Fixes: https://tracker.ceph.com/issues/73892 Signed-off-by: Eric Zhang --- .../tests/test_overlapping_roots.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/pybind/mgr/pg_autoscaler/tests/test_overlapping_roots.py b/src/pybind/mgr/pg_autoscaler/tests/test_overlapping_roots.py index b82146f7fc0..b8278a4c80d 100644 --- a/src/pybind/mgr/pg_autoscaler/tests/test_overlapping_roots.py +++ b/src/pybind/mgr/pg_autoscaler/tests/test_overlapping_roots.py @@ -3,7 +3,9 @@ import unittest from tests import mock import pytest import json +from collections import defaultdict from pg_autoscaler import module +from pg_autoscaler.module import CrushRootResourceStatus class OSDMAP: @@ -45,17 +47,16 @@ class TestPgAutoscaler(object): def setup_method(self): # a bunch of attributes for testing. self.autoscaler = module.PgAutoscaler('module_name', 0, 0) + self.autoscaler.mon_target_pg_per_osd = 100 - def helper_test(self, osd_dic, rules, pools, expected_overlapped_roots): - result = {} - roots = [] - overlapped_roots = set() + def helper_test(self, osd_dic, rules, pools, expected_result): osdmap = OSDMAP(pools) crush = CRUSH(rules, osd_dic) - roots, overlapped_roots = self.autoscaler.identify_subtrees_and_overlaps( - osdmap, pools, crush, result, overlapped_roots, roots + result = self.autoscaler.get_subtree_resource_status( + osdmap, pools, crush ) - assert overlapped_roots == expected_overlapped_roots + for root_id in result: + assert result[root_id].pg_target == expected_result[root_id].pg_target def test_subtrees_and_overlaps(self): osd_dic = { @@ -282,8 +283,13 @@ class TestPgAutoscaler(object): "expected_final_pg_target": 128, }, } - expected_overlapped_roots = {-40, -1, -5} - self.helper_test(osd_dic, rules, pools, expected_overlapped_roots) + expected_result = defaultdict(CrushRootResourceStatus) + for root in osd_dic: + expected_result[root] = CrushRootResourceStatus() + expected_result[-1].pg_target = 550 + expected_result[-40].pg_target = 250 + expected_result[-5].pg_target = 800 + self.helper_test(osd_dic, rules, pools, expected_result) def test_no_overlaps(self): osd_dic = { @@ -510,5 +516,10 @@ class TestPgAutoscaler(object): "expected_final_pg_target": 128, }, } - expected_overlapped_roots = set() - self.helper_test(osd_dic, rules, pools, expected_overlapped_roots) + expected_result = defaultdict(CrushRootResourceStatus) + for root in osd_dic: + expected_result[root] = CrushRootResourceStatus() + expected_result[-1].pg_target = 1100 + expected_result[-40].pg_target = 500 + expected_result[-5].pg_target = 300 + self.helper_test(osd_dic, rules, pools, expected_result) From 6831e7c94a6ff9d4a3993a2ecb5d38962ee01745 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Wed, 27 May 2026 11:48:04 -0700 Subject: [PATCH 132/596] docs: Add docs about allowing PG autoscaling for overlapped roots Remove mention of overlapped roots failing to scale and added example of adding overlapped roots. Added mention of changes in PendingReleaseNotes Fixes: https://tracker.ceph.com/issues/73892 Signed-off-by: Eric Zhang --- PendingReleaseNotes | 4 ++ doc/rados/operations/placement-groups.rst | 54 ++++++++++++++++------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index e330cc903b9..488ad73f8d9 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -1,3 +1,7 @@ +* RADOS: PG autoscaler allows overlapping roots. Each root receives a PG target based + on its OSDs, with OSDs shared across multiple roots contributing proportionally + less to each root's allocation. + * librados/neorados: The C++ APIs for executing Ceph Class (CLS) methods have undergone a breaking change to enforce compile-time type safety, replacing legacy string-based parameters with strongly-typed ClsMethod structs. C++ diff --git a/doc/rados/operations/placement-groups.rst b/doc/rados/operations/placement-groups.rst index bbc7af9e826..0a5ed934449 100644 --- a/doc/rados/operations/placement-groups.rst +++ b/doc/rados/operations/placement-groups.rst @@ -232,28 +232,48 @@ command: For all but the very smallest deployments a value of 200 is recommended. A value above 500 may result in excessive peering traffic and RAM usage. -The autoscaler analyzes pools and adjusts on a per-subtree basis. Because each -pool might map to a different CRUSH rule, and each rule might distribute data -across different and possibly overlapping sets of devices, -Ceph will consider the utilization of each subtree of -the CRUSH hierarchy independently. For example, a pool that maps to OSDs of class -``ssd`` and a pool that maps to OSDs of class ``hdd`` will each have calculated PG -counts that are determined by how many OSDs of these two different device types -there are. +.. _overlapping_crush_roots: -If a pool uses OSDs under two or more CRUSH roots (for example, shadow trees -with both ``ssd`` and ``hdd`` devices), the autoscaler issues a warning to the -user in the manager log. The warning states the name of the pool and the set of -roots that overlap each other. The autoscaler does not scale any pools with -overlapping roots because this condition can cause problems with the scaling -process. We recommend constraining each pool so that it belongs to only one -root (that is, one device OSD class) to silence the warning and ensure successful -scaling. +Overlapping CRUSH Roots PG Budget +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When OSDs are distributed across multiple CRUSH roots, each root receives a PG target based +on its OSDs, with OSDs shared across multiple roots contributing proportionally less to each +root's allocation. The budget assigned to each root is: + +.. math:: + + \sum_{\text{OSD}_i \in R} \frac{\text{mon_target_pg_per_osd}}{|\text{roots}(\text{OSD}_i)|} + +This ensures that the total PG budget is distributed proportionally across all roots. + +Consider a cluster with the following topology: + +.. prompt:: bash # + + mon_target_pg_per_osd = 300 + +- **rootid -1**: Contains OSDs {0, 1, 2, 3} +- **rootid -2**: Contains OSDs {0, 1} +- **rootid -3**: Contains OSDs {2, 3} + +OSD membership: + +- **OSD 0**: Belongs to roots {-1, -2} +- **OSD 1**: Belongs to roots {-1, -2} +- **OSD 2**: Belongs to roots {-1, -3} +- **OSD 3**: Belongs to roots {-1, -3} + +The PG target allocation for each root is calculated as follows: + +- Root -1: pg_target = 600 = (300 / 2 roots for OSD 0) + (300 / 2 roots for OSD 1) + (300 / 2 roots for OSD 2) + (300 / 2 roots for OSD 3) +- Root -2: pg_target = 300 = (300 / 2 roots for OSD 0) + (300 / 2 roots for OSD 1) +- Root -3: pg_target = 300 = (300 / 2 roots for OSD 2) + (300 / 2 roots for OSD 3) .. _allocation_algorithm: Allocation Algorithm -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ The autoscaler sets each pool's ``final_pool_pg_target`` to be rounded to the nearest power of two while ensuring that the number of PGs to be placed on each OSD From 7f9e383ecba2a0d72cb774e30a10f8d9fed96661 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Wed, 27 May 2026 15:55:59 -0700 Subject: [PATCH 133/596] qa: Add overlapped roots test case Added overlapped roots test case for CRUSH rules rule zone-1-only step take zone us-east-1 step chooseleaf firstn 3 type host step emit rule zone-2-only step take zone us-east-2 step chooseleaf firstn 3 type host step emit rule zone-3-only step take zone us-east-3 step chooseleaf firstn 3 type host step emit rule default step take default step chooseleaf firstn 3 type host step emit and osd tree -1 6.00000 root default -8 6.00000 region us-east -5 2.00000 zone us-east-1 -13 1.00000 host host0 0 ssd 1.00000 osd.0 -14 1.00000 host host1 1 ssd 1.00000 osd.1 -6 2.00000 zone us-east-2 -15 1.00000 host host2 2 ssd 1.00000 osd.2 -16 1.00000 host host3 3 ssd 1.00000 osd.3 -7 2.00000 zone us-east-3 -17 1.00000 host host4 4 ssd 1.00000 osd.4 -18 1.00000 host host5 5 ssd 1.00000 osd.5 -1: pg_target = 300 -5: pg_target = 100 -6 pg_target = 100 -7: pg_target = 100 Fixes: https://tracker.ceph.com/issues/73892 Signed-off-by: Eric Zhang --- qa/workunits/mon/pg_autoscaler.sh | 148 +++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/qa/workunits/mon/pg_autoscaler.sh b/qa/workunits/mon/pg_autoscaler.sh index b7eabbe92b0..bd045df7e4e 100755 --- a/qa/workunits/mon/pg_autoscaler.sh +++ b/qa/workunits/mon/pg_autoscaler.sh @@ -139,7 +139,6 @@ function test_autoscaler_basic() { wait_for 60 "ceph health detail | grep POOL_HAS_TARGET_SIZE_BYTES_AND_RATIO" # test autoscale warn - ceph osd pool create warn0 1 --autoscale-mode=warn wait_for 120 "ceph health detail | grep POOL_TOO_FEW_PGS" @@ -212,14 +211,161 @@ function test_exact_budget() { ceph osd pool rm data1 data1 --yes-i-really-really-mean-it } +function test_overlapping_roots() { + # Create custom CRUSH rules for zone-based placement + MON_TARGET_PG_PER_OSD=100 + ceph config set global mon_target_pg_per_osd $MON_TARGET_PG_PER_OSD + + ceph osd crush add-bucket us-east region + ceph osd crush move us-east root=default + + ceph osd crush add-bucket us-east-1 zone + ceph osd crush add-bucket us-east-2 zone + ceph osd crush add-bucket us-east-3 zone + ceph osd crush move us-east-1 region=us-east + ceph osd crush move us-east-2 region=us-east + ceph osd crush move us-east-3 region=us-east + + ceph osd crush add-bucket host0 host + ceph osd crush add-bucket host1 host + ceph osd crush add-bucket host2 host + ceph osd crush add-bucket host3 host + ceph osd crush add-bucket host4 host + ceph osd crush add-bucket host5 host + + ceph osd crush move host0 zone=us-east-1 + ceph osd crush move host1 zone=us-east-1 + ceph osd crush move host2 zone=us-east-2 + ceph osd crush move host3 zone=us-east-2 + ceph osd crush move host4 zone=us-east-3 + ceph osd crush move host5 zone=us-east-3 + + ceph osd crush set osd.0 1.0 host=host0 + ceph osd crush set osd.1 1.0 host=host1 + ceph osd crush set osd.2 1.0 host=host2 + ceph osd crush set osd.3 1.0 host=host3 + ceph osd crush set osd.4 1.0 host=host4 + ceph osd crush set osd.5 1.0 host=host5 + + ceph osd crush move us-east root=default + + # Add zone-based CRUSH rules + hostname=$(hostname -s) + ceph osd crush remove $hostname + + ceph osd getcrushmap > crushmap + crushtool --decompile crushmap > crushmap.txt + sed 's/^# end crush map$//' crushmap.txt > crushmap_modified.txt + cat >> crushmap_modified.txt << EOF +rule zone-1-only { + id 1 + type replicated + step take us-east-1 + step chooseleaf firstn 3 type host + step emit +} + +rule zone-2-only { + id 2 + type replicated + step take us-east-2 + step chooseleaf firstn 3 type host + step emit +} + +rule zone-3-only { + id 3 + type replicated + step take us-east-3 + step chooseleaf firstn 3 type host + step emit +} + +rule default { + id 4 + type replicated + step take default + step chooseleaf firstn 3 type host + step emit +} +# end crush map +EOF + + # compile the modified crushmap and set it + crushtool --compile crushmap_modified.txt -o crushmap.bin + ceph osd setcrushmap -i crushmap.bin + + + ceph osd pool create data0 --size=1 + ceph osd pool create data1 --size=1 + ceph osd pool create data2 --size=1 + ceph osd pool create data3 --size=3 + + ceph osd pool set data0 pg_autoscale_mode on + ceph osd pool set data1 pg_autoscale_mode on + ceph osd pool set data2 pg_autoscale_mode on + ceph osd pool set data3 pg_autoscale_mode on + + + ceph osd pool set data0 crush_rule zone-1-only + ceph osd pool set data1 crush_rule zone-2-only + ceph osd pool set data2 crush_rule zone-3-only + ceph osd pool set data3 crush_rule default + + ceph osd pool set data0 target_size_ratio 1.0 + ceph osd pool set data1 target_size_ratio 1.0 + ceph osd pool set data2 target_size_ratio 1.0 + ceph osd pool set data3 target_size_ratio 1.0 + + # expect + # -1 6.00000 root default + # -8 6.00000 region us-east + # -5 2.00000 zone us-east-1 + # -13 1.00000 host host0 + # 0 ssd 1.00000 osd.0 + # -14 1.00000 host host1 + # 1 ssd 1.00000 osd.1 + # -6 2.00000 zone us-east-2 + # -15 1.00000 host host2 + # 2 ssd 1.00000 osd.2 + # -16 1.00000 host host3 + # 3 ssd 1.00000 osd.3 + # -7 2.00000 zone us-east-3 + # -17 1.00000 host host4 + # 4 ssd 1.00000 osd.4 + # -18 1.00000 host host5 + # 5 ssd 1.00000 osd.5 + + # -1: pg_target = 300 + # -5: pg_target = 100 + # -6 pg_target = 100 + # -7: pg_target = 100 + + TARGET_PG_1=$(power2_floor $MON_TARGET_PG_PER_OSD) + TARGET_PG_2=$(power2_floor $(( ($NUM_OSDS - 3) * $MON_TARGET_PG_PER_OSD ))) + + + wait_for 300 "ceph osd pool get data0 pg_num | grep $TARGET_PG_1" + wait_for 300 "ceph osd pool get data1 pg_num | grep $TARGET_PG_1" + wait_for 300 "ceph osd pool get data2 pg_num | grep $TARGET_PG_1" + wait_for 300 "ceph osd pool get data3 pg_num | grep $TARGET_PG_2" + + ceph osd pool rm data0 data0 --yes-i-really-really-mean-it + ceph osd pool rm data1 data1 --yes-i-really-really-mean-it + ceph osd pool rm data2 data2 --yes-i-really-really-mean-it + ceph osd pool rm data3 data3 --yes-i-really-really-mean-it +} # enable ceph config set mgr mgr/pg_autoscaler/sleep_interval 60 ceph mgr module enable pg_autoscaler +ceph osd pool set threshold 1.0 + test_autoscaler_basic || return 1 test_pool_starvation || return 1 test_exact_budget || return 1 +test_overlapping_roots || exit 1 echo OK From d2e487acfe9ed45b30d879844bfdbc7d5f1ff343 Mon Sep 17 00:00:00 2001 From: "Matthew N. Heler" Date: Tue, 9 Jun 2026 17:21:39 -0500 Subject: [PATCH 134/596] rgw/restore: take the hash mod HASH_PRIME when picking a shard choose_oid fed ceph_str_hash_linux straight into % max_objs, and the low bits of that hash are weak enough that similar object names keep landing on the same shard. LC takes the hash mod HASH_PRIME first for exactly this reason, do the same here. Signed-off-by: Matthew N. Heler --- src/rgw/rgw_restore.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rgw/rgw_restore.cc b/src/rgw/rgw_restore.cc index 0cda715e4c6..a73de2a4008 100644 --- a/src/rgw/rgw_restore.cc +++ b/src/rgw/rgw_restore.cc @@ -266,7 +266,7 @@ std::ostream& Restore::gen_prefix(std::ostream& out) const int Restore::choose_oid(const RestoreEntry& e) { int index; const auto& name = e.bucket.name + e.obj_key.name + e.obj_key.instance; - index = ((ceph_str_hash_linux(name.data(), name.size())) % max_objs); + index = ((ceph_str_hash_linux(name.data(), name.size())) % HASH_PRIME % max_objs); return static_cast(index); } From c0fda141e7cdda63c0498fda78c7250cbf3e6374 Mon Sep 17 00:00:00 2001 From: Anoop C S Date: Thu, 4 Jun 2026 18:10:46 +0530 Subject: [PATCH 135/596] cephadm/smb: Bind mount /run with 0755 The host side 'run' directory under /var/lib/ceph// bind mounted into SMB containers is created with mode 0770, preventing any non owner processes from accessing unix domain sockets or named pipes under /run. This breaks smbd to winbindd communication, causing SID to name resolution failures for AD joined deployments. Therefore change permissions to 0755 to match standard /run semantics. Fixes: https://tracker.ceph.com/issues/77120 Signed-off-by: Anoop C S --- src/cephadm/cephadmlib/daemons/smb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cephadm/cephadmlib/daemons/smb.py b/src/cephadm/cephadmlib/daemons/smb.py index 0ebb37653d6..3026d6530b7 100644 --- a/src/cephadm/cephadmlib/daemons/smb.py +++ b/src/cephadm/cephadmlib/daemons/smb.py @@ -1055,7 +1055,7 @@ class SMB(ContainerDaemonForm): etc_samba_ctr = ddir / 'etc-samba-container' file_utils.makedirs(etc_samba_ctr, uid, gid, 0o770) file_utils.makedirs(ddir / 'lib-samba', uid, gid, 0o755) - file_utils.makedirs(ddir / 'run', uid, gid, 0o770) + file_utils.makedirs(ddir / 'run', uid, gid, 0o755) if self._files: file_utils.populate_files(data_dir, self._files, uid, gid) if self._tls_files: From 31e1c7bd5e461dca0a07b6c631f738272360c549 Mon Sep 17 00:00:00 2001 From: Anoop C S Date: Sun, 7 Jun 2026 17:16:33 +0530 Subject: [PATCH 136/596] qa/smb: Test SID resolution in AD joined containers Verify that rpcclient lookupsids resolves domain user SIDs correctly inside the smbd container, preventing regressions on /run bind mount permissions that break smbd to winbindd communication. Fixes: https://tracker.ceph.com/issues/77120 Signed-off-by: Anoop C S --- .../smb/tasks/deploy_smb_mgr_domain.yaml | 2 +- qa/workunits/smb/tests/cephutil.py | 25 ++++++++++ qa/workunits/smb/tests/pytest.ini | 1 + qa/workunits/smb/tests/test_sid_resolution.py | 46 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 qa/workunits/smb/tests/test_sid_resolution.py diff --git a/qa/suites/orch/cephadm/smb/tasks/deploy_smb_mgr_domain.yaml b/qa/suites/orch/cephadm/smb/tasks/deploy_smb_mgr_domain.yaml index 3e03c04613e..d35caff6ad5 100644 --- a/qa/suites/orch/cephadm/smb/tasks/deploy_smb_mgr_domain.yaml +++ b/qa/suites/orch/cephadm/smb/tasks/deploy_smb_mgr_domain.yaml @@ -62,7 +62,7 @@ tasks: timeout: 1h clients: client.0: - - [default, hosts_access] + - [default, hosts_access, domain] - cephadm.shell: host.a: diff --git a/qa/workunits/smb/tests/cephutil.py b/qa/workunits/smb/tests/cephutil.py index 350b9db092c..3da118db4d5 100644 --- a/qa/workunits/smb/tests/cephutil.py +++ b/qa/workunits/smb/tests/cephutil.py @@ -1,5 +1,6 @@ import enum import json +import shlex import subprocess @@ -66,3 +67,27 @@ def cephadm_shell_cmd( elif load is LoadJSON.ERROR: return JSONResult(proc.returncode, None, proc.stderr.decode()) return proc + + +def cephadm_enter_cmd(smb_cfg, cluster_id, args, **kwargs): + """Run a command inside the primary smbd container for the given + cluster_id on the cluster's admin node (derived via smb_cfg). + All kwargs are treated as arguments to subprocess.run. + """ + remote_cmd = [ + 'sudo', + f'/home/{smb_cfg.ssh_user}/cephtest/cephadm', + 'enter', + '-i', + f'smb.{cluster_id}', + ] + list(args) + cmd = [ + 'ssh', + '-oBatchMode=yes', + '-oUserKnownHostsFile=/dev/null', + '-oStrictHostKeyChecking=no', + '-q', + f'{smb_cfg.ssh_user}@{smb_cfg.ssh_admin_host}', + shlex.join(remote_cmd), + ] + return subprocess.run(cmd, **kwargs) diff --git a/qa/workunits/smb/tests/pytest.ini b/qa/workunits/smb/tests/pytest.ini index abba05e6cb8..982770c13c4 100644 --- a/qa/workunits/smb/tests/pytest.ini +++ b/qa/workunits/smb/tests/pytest.ini @@ -6,3 +6,4 @@ markers = hosts_access: Host access tests rate_limiting: Rate limit tests ceph_smb_ctl_local: Local/container test of ceph-smb-ctl tool + domain: Domain integration tests diff --git a/qa/workunits/smb/tests/test_sid_resolution.py b/qa/workunits/smb/tests/test_sid_resolution.py new file mode 100644 index 00000000000..b7ec0477b38 --- /dev/null +++ b/qa/workunits/smb/tests/test_sid_resolution.py @@ -0,0 +1,46 @@ +import pytest + +import cephutil +import smbutil + + +@pytest.mark.domain +def test_sid_resolution(smb_cfg): + """Verify that rpcclient lookupsids resolves domain user SIDs correctly + inside the smbd container, preventing regressions on /run bind mount + permissions that break smbd to winbindd communication (tracker#77120). + """ + cluster_id = smbutil.get_shares(smb_cfg)[0]['cluster_id'] + username = smb_cfg.username + password = smb_cfg.password + + result = cephutil.cephadm_enter_cmd( + smb_cfg, + cluster_id, + ['wbinfo', '-n', username], + capture_output=True, + check=True, + ) + user_sid = result.stdout.decode().split()[0] + assert user_sid.startswith('S-'), f'unexpected SID format: {user_sid}' + + auth = f'{username}%{password}' + result = cephutil.cephadm_enter_cmd( + smb_cfg, + cluster_id, + [ + 'rpcclient', + 'localhost', + '-U', + auth, + '-c', + f'lookupsids {user_sid}', + ], + capture_output=True, + check=True, + ) + output = result.stdout.decode() + short_name = username.split('\\')[-1] + assert short_name in output, ( + f'SID resolution failed: {short_name!r} not found in: {output}' + ) From 8e7dc4e4e742b9b90c9dafb84af4107ab2d91a4e Mon Sep 17 00:00:00 2001 From: Devika Babrekar Date: Wed, 10 Jun 2026 20:11:48 +0530 Subject: [PATCH 137/596] mgr/dashboard: Fix for EC profile creation modal scrollbar Fixes: https://tracker.ceph.com/issues/77298 Signed-off-by: Devika Babrekar --- ...ure-code-profile-form-modal.component.html | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html index 2bcb4ce81a6..3f58b19c88c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html @@ -1,7 +1,7 @@ - + + + + From 78dad340ab180532082c39a52efc0dfce244ec6e Mon Sep 17 00:00:00 2001 From: Syed Ali Ul Hasan Date: Wed, 10 Jun 2026 23:00:36 +0530 Subject: [PATCH 138/596] mgr/dashboard: carbonized OSD form component Fixes: https://tracker.ceph.com/issues/68265 Signed-off-by: Syed Ali Ul Hasan --- .../src/app/ceph/cluster/cluster.module.ts | 6 +- .../create-cluster-step-2.component.html | 15 +- .../create-cluster-step-2.component.ts | 10 + ...sd-devices-selection-groups.component.html | 144 ++-- ...sd-devices-selection-groups.component.scss | 14 + .../osd-devices-selection-groups.component.ts | 87 ++- .../osd/osd-form/osd-form.component.html | 640 ++++++++++++------ .../osd/osd-form/osd-form.component.scss | 24 + .../osd/osd-form/osd-form.component.spec.ts | 163 +++-- .../osd/osd-form/osd-form.component.ts | 270 +++++++- .../osd/osd-list/osd-list.component.html | 123 ++-- .../osd/osd-list/osd-list.component.ts | 25 +- .../tearsheet/tearsheet.component.ts | 8 +- .../src/app/shared/models/osd-form.ts | 5 + .../src/styles/ceph-custom/_spacings.scss | 8 + 15 files changed, 1137 insertions(+), 405 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index a711704ad6e..3edd74a4397 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -27,7 +27,8 @@ import { TabsModule, RadioModule, TilesModule, - LayerModule + LayerModule, + AccordionModule } from 'carbon-components-angular'; import Analytics from '@carbon/icons/es/analytics/16'; import CloseFilled from '@carbon/icons/es/close--filled/16'; @@ -145,7 +146,8 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ FileUploaderModule, RadioModule, TilesModule, - LayerModule + LayerModule, + AccordionModule ], declarations: [ MonitorComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html index c847a491e7d..1523bdb986b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html @@ -8,11 +8,16 @@

Create OSDs

- + @if (showForm) { + + } @else { + + }
- - -
- - {{ filter.name }}: {{ filter.value.formatted }} - - - - Clear - -
-
- - -
-
- Raw capacity: {{ capacity | dimlessBinary }} -
-
+ @if (devices.length === 0 && inlineSelection) { + @if (!canSelect) { + + {{ tooltips.addPrimaryFirst }} + + } + @if (canSelect) { + @if (availDevices.length === 0) { + + No available devices + + } + @if (availDevices.length > 0 && !canInlineSubmit) { + + At least one of these filters must be applied in order to proceed: + @for (filter of requiredFilters; track filter) { + {{ filter }} + } + + } + + + @if (canInlineSubmit) { +
+ Number of devices: {{ inlineFilteredDevices.length }}. Raw capacity: + {{ inlineCapacity | dimlessBinary }}. +
+ } + + } + } @else { + @if (devices.length === 0) { + + } @else { +
+ + {{ filter.name }}: {{ filter.value.formatted }} + + + + Clear + +
+
+ + +
+ @if (type === 'data') { +
+ Raw capacity: {{ capacity | dimlessBinary }} +
+ } + } + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss index 3fb8f6b3848..bf3223c234d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss @@ -1,3 +1,17 @@ .tc_clearSelections { text-decoration: none; } + +.osd-devices-selection-row { + .cd-form-label, + .cd-col-form-input { + flex: 0 0 100%; + max-width: 100%; + width: 100%; + } + + cd-inventory-devices { + display: block; + width: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts index 5acb7c51b99..79d03d10b45 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts @@ -31,6 +31,8 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { @Input() canSelect: boolean; + @Input() inlineSelection = false; + @Output() selected = new EventEmitter(); @@ -45,6 +47,23 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { isOsdPage: boolean; addButtonTooltip: String; + filterColumns = [ + 'hostname', + 'human_readable_type', + 'sys_api.vendor', + 'sys_api.model', + 'sys_api.size' + ]; + requiredFilters: string[] = [ + $localize`Type`, + $localize`Vendor`, + $localize`Model`, + $localize`Size` + ]; + inlineFilteredDevices: InventoryDevice[] = []; + inlineCapacity = 0; + canInlineSubmit = false; + inlineFilterEvent?: CdTableColumnFiltersChange; tooltips = { noAvailDevices: $localize`No available devices`, addPrimaryFirst: $localize`Please add primary devices first`, @@ -77,39 +96,65 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { } showSelectionModal() { - const filterColumns = [ - 'hostname', - 'human_readable_type', - 'sys_api.vendor', - 'sys_api.model', - 'sys_api.size' - ]; const diskType = this.name === 'Primary' ? 'hdd' : 'ssd'; const initialState = { hostname: this.hostname, deviceType: this.name, diskType: diskType, devices: this.availDevices, - filterColumns: filterColumns + filterColumns: this.filterColumns }; const modalRef = this.modalService.show(OsdDevicesSelectionModalComponent, initialState, { size: 'xl' }); modalRef.componentInstance.submitAction.subscribe((result: CdTableColumnFiltersChange) => { - this.devices = result.data; - this.capacity = _.sumBy(this.devices, 'sys_api.size'); - this.appliedFilters = result.filters; - const event = _.assign({ type: this.type }, result); - if (!this.isOsdPage) { - this.osdService.osdDevices[this.type] = this.devices; - this.osdService.osdDevices['disableSelect'] = - this.canSelect || this.devices.length === this.availDevices.length; - this.osdService.osdDevices[this.type]['capacity'] = this.capacity; - } - this.selected.emit(event); + this.applySelectionResult(result); }); } + onInlineFilterChange(event: CdTableColumnFiltersChange) { + this.inlineCapacity = 0; + this.canInlineSubmit = false; + this.inlineFilterEvent = undefined; + + if (_.isEmpty(event.filters)) { + this.inlineFilteredDevices = []; + return; + } + + const filters = event.filters.filter((filter) => filter.prop !== 'hostname'); + this.canInlineSubmit = !_.isEmpty(filters); + this.inlineFilteredDevices = event.data; + this.inlineCapacity = _.sumBy(this.inlineFilteredDevices, 'sys_api.size'); + this.inlineFilterEvent = event; + } + + submitInlineSelection() { + if ( + !this.inlineFilterEvent || + !this.canInlineSubmit || + this.inlineFilteredDevices.length === 0 + ) { + return; + } + + this.applySelectionResult(this.inlineFilterEvent); + } + + private applySelectionResult(result: CdTableColumnFiltersChange) { + this.devices = result.data; + this.capacity = _.sumBy(this.devices, 'sys_api.size'); + this.appliedFilters = result.filters; + const event = _.assign({ type: this.type }, result); + if (!this.isOsdPage) { + this.osdService.osdDevices[this.type] = this.devices; + this.osdService.osdDevices['disableSelect'] = + this.canSelect || this.devices.length === this.availDevices.length; + this.osdService.osdDevices[this.type]['capacity'] = this.capacity; + } + this.selected.emit(event); + } + private updateAddButtonTooltip() { if (this.type === 'data' && this.availDevices.length === 0) { this.addButtonTooltip = this.tooltips.noAvailDevices; @@ -136,6 +181,10 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { clearedDevices: [...this.devices] }; this.devices = []; + this.inlineFilteredDevices = []; + this.inlineCapacity = 0; + this.canInlineSubmit = false; + this.inlineFilterEvent = undefined; this.cleared.emit(event); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html index d232aba2339..d536d41a9c6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html @@ -1,221 +1,449 @@ - +@if (!hasOrchestrator) { + +} -
-
{{ action | titlecase }} {{ resource | upperFirst }}
-
-
- -
- No eligible devices found for OSD creation. - Physical disks may be present, but none meet the requirements (unused, unformatted, and not already configured by Ceph). -
-
-
-
-

- -

-
-
-
-
-
- - + + + +
+ + + @if (availDevices?.length === 0) { + +
+ + No eligible devices found for OSD creation. + + + Physical disks may be present, but none meet the requirements + (unused, unformatted, and not already configured by Ceph). + +
+
+ } + +
+ + Deployment Options + + +
+ + + +
+ + Automatic + +
+ Choose a pre-configured profile for you.
- - -
+
+ + } +
-
-

- -

-
-
-
-
-
- - -
+ } - -
- Shared devices - - - - - - -
- -
- - Value should be greater than or equal to 0 -
-
- - - - - - -
- -
- - Value should be greater than or equal to 0 -
-
-
-
-
-
- - -
-

- -

-
-
-
-
-
- - + +
+ + Manual selection + +
+ Custom Configuration
+
+ +
+
+ + +
+ + + @if (form.get('deploymentMode').value === 'manual') { + +
+
+ +
+ + Select data devices + + + +
+ +
+
+
+ + +
+
+ +
+ + Select DB/WAL devices (optional) + + +
+ + + + @if (walDeviceSelectionGroups.devices.length !== 0) { +
+ +
+ } + + + + + @if (dbDeviceSelectionGroups.devices.length !== 0) { +
+ + +
+ }
+
+
+ } - + + + +
+
+
+

Review summary

+
+ +
+

Deployment mode

+

{{ reviewDeploymentModeLabel }}

+
+ + @if (simpleDeployment) { +
+

Profile

+

{{ reviewDeploymentOptionTitle }}

+
+ +
+

Profile details

+

{{ reviewDeploymentOptionDescription }}

+
+ } @else { +
+

Host pattern

+

{{ reviewHostPattern }}

+
+ +
+

Device selections

+
+ +
+

Data devices

+ @if (reviewDataSelection.hasSelection) { +

{{ reviewDataSelection.count }} device(s) selected

+

Total capacity: {{ reviewDataSelection.capacity }}

+ @if (reviewDataSelection.filters.length > 0) { +
+ @for (filter of reviewDataSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ +
+

WAL devices

+ @if (reviewWalSelection.hasSelection) { +

{{ reviewWalSelection.count }} device(s) selected

+

Total capacity: {{ reviewWalSelection.capacity }}

+

WAL slots: {{ reviewWalSelection.slots }}

+ @if (reviewWalSelection.filters.length > 0) { +
+ @for (filter of reviewWalSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ +
+

DB devices

+ @if (reviewDbSelection.hasSelection) { +

{{ reviewDbSelection.count }} device(s) selected

+

Total capacity: {{ reviewDbSelection.capacity }}

+

DB slots: {{ reviewDbSelection.slots }}

+ @if (reviewDbSelection.filters.length > 0) { +
+ @for (filter of reviewDbSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ } + +
+

Features

+
+ +
+ @if (reviewEnabledFeatures.length > 0) { + @for (feature of reviewEnabledFeatures; track feature) { +

{{ feature }}

+ } + } @else { +

No features enabled

+ } +
+
+
+
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss index e69de29bb2d..6322e7e939b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss @@ -0,0 +1,24 @@ +.osd-tearsheet-content { + padding: var(--cds-spacing-05) var(--cds-spacing-06); + max-height: calc(100vh - 300px); + overflow-y: auto; + overflow-x: hidden; +} + +.osd-alert-block { + display: block; +} + +.osd-radio-label-wrapper { + display: flex; + flex-direction: column; +} + +.osd-radio-helper-text { + max-width: 25rem; + white-space: normal; +} + +.osd-review-section { + border-left: 1px solid var(--cds-border-subtle-01); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts index 9cd09bfa2a7..5f46fc4c677 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { CheckboxModule, NumberModule, RadioModule } from 'carbon-components-angular'; import { BehaviorSubject, of } from 'rxjs'; @@ -21,6 +22,7 @@ import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; +import { OsdDeviceType } from '~/app/shared/models/osd-form'; import { OsdFormComponent } from './osd-form.component'; describe('OsdFormComponent', () => { @@ -93,11 +95,17 @@ describe('OsdFormComponent', () => { }; const expectPreviewButton = (enabled: boolean) => { - const debugElement = fixtureHelper.getElementByCss('.tc_submitButton'); - expect(debugElement.nativeElement.disabled).toBe(!enabled); + expect(component.dataDeviceSelectionGroups.devices.length > 0).toBe(enabled); }; - const selectDevices = (type: string) => { + const ensureSelectionGroups = () => { + component.dataDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + component.walDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + component.dbDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + }; + + const selectDevices = (type: OsdDeviceType) => { + ensureSelectionGroups(); const event: DevicesSelectionChangeEvent = { type: type, filters: [], @@ -105,31 +113,39 @@ describe('OsdFormComponent', () => { dataOut: [] }; component.onDevicesSelected(event); - if (type === 'data') { + if (type === OsdDeviceType.DATA) { component.dataDeviceSelectionGroups.devices = devices; - } else if (type === 'wal') { + } else if (type === OsdDeviceType.WAL) { component.walDeviceSelectionGroups.devices = devices; - } else if (type === 'db') { + } else if (type === OsdDeviceType.DB) { component.dbDeviceSelectionGroups.devices = devices; } fixture.detectChanges(); }; - const clearDevices = (type: string) => { + const clearDevices = (type: OsdDeviceType) => { + ensureSelectionGroups(); const event: DevicesSelectionClearEvent = { type: type, clearedDevices: [] }; component.onDevicesCleared(event); + if (type === OsdDeviceType.DATA) { + component.dataDeviceSelectionGroups.devices = []; + } else if (type === OsdDeviceType.WAL) { + component.walDeviceSelectionGroups.devices = []; + } else if (type === OsdDeviceType.DB) { + component.dbDeviceSelectionGroups.devices = []; + } fixture.detectChanges(); }; const features = ['encrypted']; const checkFeatures = (enabled: boolean) => { for (const feature of features) { - const element = fixtureHelper.getElementByCss(`#${feature}`).nativeElement; - expect(element.disabled).toBe(!enabled); - expect(element.checked).toBe(false); + const control = form.get(feature); + expect(control.disabled).toBe(!enabled); + expect(control.value).toBe(false); } }; @@ -138,6 +154,9 @@ describe('OsdFormComponent', () => { BrowserAnimationsModule, HttpClientTestingModule, FormsModule, + RadioModule, + CheckboxModule, + NumberModule, SharedModule, RouterTestingModule, ReactiveFormsModule @@ -182,48 +201,95 @@ describe('OsdFormComponent', () => { describe('with orchestrator', () => { beforeEach(() => { - component.simpleDeployment = false; spyOn(orchService, 'status').and.returnValue(of({ available: true })); spyOn(hostService, 'inventoryDeviceList').and.returnValue(of([])); component.deploymentOptions = deploymentOptions; fixture.detectChanges(); + ensureSelectionGroups(); }); it('should display the accordion', () => { - fixtureHelper.expectElementVisible('.card-body .accordion', true); + expect(component.hasOrchestrator).toBe(true); + expect(component.optionNames).toEqual([ + OsdDeploymentOptions.COST_CAPACITY, + OsdDeploymentOptions.THROUGHPUT, + OsdDeploymentOptions.IOPS + ]); + expect(component.steps).toHaveLength(3); + expect(component.steps.map((step) => step.label)).toEqual([ + 'Deployment Options', + 'Features', + 'Review' + ]); + }); + + it('should expand and collapse steps when deployment mode changes', () => { + component.form.get('deploymentMode').setValue('manual'); + fixture.detectChanges(); + + expect(component.steps).toHaveLength(5); + expect(component.steps.map((step) => step.label)).toEqual([ + 'Deployment Options', + 'Select data devices', + 'Select DB/WAL devices (optional)', + 'Features', + 'Review' + ]); + expect(fixture.nativeElement.textContent).toContain('Select data devices'); + expect(fixture.nativeElement.textContent).toContain('Select DB/WAL devices (optional)'); + + component.form.get('deploymentMode').setValue('automatic'); + fixture.detectChanges(); + + expect(component.steps).toHaveLength(3); + expect(component.steps.map((step) => step.label)).toEqual([ + 'Deployment Options', + 'Features', + 'Review' + ]); + expect(fixture.nativeElement.textContent).not.toContain('Select data devices'); + expect(fixture.nativeElement.textContent).not.toContain('Select DB/WAL devices (optional)'); + }); + + it('should populate automatic review data', () => { + component.form.get('deploymentOption').setValue(OsdDeploymentOptions.COST_CAPACITY); + + component.populateReviewData(); + + expect(component.reviewDeploymentModeLabel).toBe('Automatic'); + expect(component.reviewDeploymentOptionTitle).toBe('Cost/Capacity-optimized'); + expect(component.reviewDeploymentOptionDescription).toBe( + 'All the available HDDs are selected' + ); + expect(component.reviewEnabledFeatures).toEqual([]); }); it('should display the three deployment scenarios', () => { - fixtureHelper.expectElementVisible('#cost_capacity', true); - fixtureHelper.expectElementVisible('#throughput_optimized', true); - fixtureHelper.expectElementVisible('#iops_optimized', true); + const text = fixture.nativeElement.textContent; + expect(text).toContain('Cost/Capacity-optimized'); + expect(text).toContain('Throughput-optimized'); + expect(text).toContain('IOPS-optimized'); }); it('should only disable the options that are not available', () => { - let radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement; - expect(radioBtn.disabled).toBeTruthy(); - radioBtn = fixtureHelper.getElementByCss('#iops_optimized').nativeElement; - expect(radioBtn.disabled).toBeTruthy(); + expect(deploymentOptions.options['throughput_optimized'].available).toBeFalsy(); + expect(deploymentOptions.options['iops_optimized'].available).toBeFalsy(); - // Make the throughput_optimized option available and verify the option is not disabled deploymentOptions.options['throughput_optimized'].available = true; fixture.detectChanges(); - radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement; - expect(radioBtn.disabled).toBeFalsy(); + expect(deploymentOptions.options['throughput_optimized'].available).toBeTruthy(); }); it('should be a Recommended option only when it is recommended by backend', () => { - const label = fixtureHelper.getElementByCss('#label_cost_capacity').nativeElement; - const throughputLabel = fixtureHelper.getElementByCss('#label_throughput_optimized') - .nativeElement; - - expect(label.innerHTML).toContain('Recommended'); - expect(throughputLabel.innerHTML).not.toContain('Recommended'); + let text = fixture.nativeElement.textContent; + expect(text).toContain('Cost/Capacity-optimized'); + expect(text).toContain('(Recommended)'); deploymentOptions.recommended_option = OsdDeploymentOptions.THROUGHPUT; fixture.detectChanges(); - expect(throughputLabel.innerHTML).toContain('Recommended'); - expect(label.innerHTML).not.toContain('Recommended'); + text = fixture.nativeElement.textContent; + expect(text).toContain('Throughput-optimized'); + expect(text).toContain('(Recommended)'); }); describe('without data devices selected', () => { @@ -244,7 +310,7 @@ describe('OsdFormComponent', () => { describe('with data devices selected', () => { beforeEach(() => { - selectDevices('data'); + selectDevices(OsdDeviceType.DATA); }); it('should enable preview button', () => { @@ -261,37 +327,54 @@ describe('OsdFormComponent', () => { }); it('should disable the checkboxes after clearing data devices', () => { - clearDevices('data'); + clearDevices(OsdDeviceType.DATA); checkFeatures(false); }); describe('with shared devices selected', () => { beforeEach(() => { - selectDevices('wal'); - selectDevices('db'); + selectDevices(OsdDeviceType.WAL); + selectDevices(OsdDeviceType.DB); + }); + + it('should populate manual review data', () => { + component.form.get('deploymentMode').setValue('manual'); + component.form.get('walSlots').setValue(2); + component.form.get('dbSlots').setValue(1); + + component.populateReviewData(); + + expect(component.reviewDeploymentModeLabel).toBe('Manual selection'); + expect(component.reviewDataSelection.count).toBe(1); + expect(component.reviewWalSelection.count).toBe(1); + expect(component.reviewWalSelection.slots).toBe(2); + expect(component.reviewDbSelection.count).toBe(1); + expect(component.reviewDbSelection.slots).toBe(1); }); it('should display slots', () => { - fixtureHelper.expectElementVisible('#walSlots', true); - fixtureHelper.expectElementVisible('#dbSlots', true); + expect(component.walDeviceSelectionGroups.devices.length).toBeGreaterThan(0); + expect(component.dbDeviceSelectionGroups.devices.length).toBeGreaterThan(0); }); it('validate slots', () => { for (const control of ['walSlots', 'dbSlots']) { formHelper.expectValid(control); formHelper.expectValidChange(control, 1); - formHelper.expectErrorChange(control, -1, 'min'); + formHelper.expectValidChange(control, -1); } + expect(component.driveGroup.spec['wal_slots']).toBe(1); + expect(component.driveGroup.spec['db_slots']).toBe(1); }); describe('test clearing data devices', () => { beforeEach(() => { - clearDevices('data'); + clearDevices(OsdDeviceType.DATA); }); it('should not display shared devices slots and should disable checkboxes', () => { - fixtureHelper.expectElementVisible('#walSlots', false); - fixtureHelper.expectElementVisible('#dbSlots', false); + expect(component.walDeviceSelectionGroups.devices.length).toBe(0); + expect(component.dbDeviceSelectionGroups.devices.length).toBe(0); checkFeatures(false); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts index 466c45c0485..ded0a1be5c9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts @@ -28,14 +28,38 @@ import { OsdDeploymentOptions } from '~/app/shared/models/osd-deployment-options'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; import { ModalService } from '~/app/shared/services/modal.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; import { OsdCreationPreviewModalComponent } from '../osd-creation-preview-modal/osd-creation-preview-modal.component'; import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; import { DriveGroup } from './drive-group.model'; import { OsdFeature } from './osd-feature.interface'; +import { Step } from 'carbon-components-angular'; + +interface ReviewField { + label: string; + value: string; +} + +interface ReviewDeviceSelection { + count: number; + capacity: string; + filters: ReviewField[]; + slots: number | null; + hasSelection: boolean; +} + +const STEP_LABELS = { + DEPLOYMENT: $localize`Deployment Options`, + DATA: $localize`Select data devices`, + DB_WAL: $localize`Select DB/WAL devices (optional)`, + FEATURES: $localize`Features`, + REVIEW: $localize`Review` +} as const; @Component({ selector: 'cd-osd-form', @@ -44,33 +68,35 @@ import { OsdFeature } from './osd-feature.interface'; standalone: false }) export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { + @ViewChild(TearsheetComponent) + tearsheet!: TearsheetComponent; + @ViewChild('dataDeviceSelectionGroups') - dataDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + dataDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('walDeviceSelectionGroups') - walDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + walDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('dbDeviceSelectionGroups') - dbDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + dbDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('previewButtonPanel') - previewButtonPanel: FormButtonPanelComponent; + previewButtonPanel!: FormButtonPanelComponent; @Input() hideTitle = false; - @Input() - hideSubmitBtn = false; - @Output() emitDriveGroup: EventEmitter = new EventEmitter(); @Output() emitDeploymentOption: EventEmitter = new EventEmitter(); @Output() emitMode: EventEmitter = new EventEmitter(); + @Output() osdCreated: EventEmitter = new EventEmitter(); + icons = Icons; - form: CdFormGroup; + form!: CdFormGroup; columns: Array = []; allDevices: InventoryDevice[] = []; @@ -86,21 +112,35 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { resource: string; features: { [key: string]: OsdFeature }; - featureList: OsdFeature[] = []; + featureList: Array = []; hasOrchestrator = true; simpleDeployment = true; + createOsdsLabel = $localize`Create OSDs`; + isSubmitLoading = false; - deploymentOptions: DeploymentOptions; + deploymentOptions!: DeploymentOptions; optionNames = Object.values(OsdDeploymentOptions); + steps: Array = this.getStepsForMode('automatic'); + + reviewDeploymentModeLabel = $localize`Automatic`; + reviewDeploymentOptionTitle = ''; + reviewDeploymentOptionDescription = ''; + reviewHostPattern = ''; + reviewEnabledFeatures: string[] = []; + reviewDataSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + reviewWalSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + reviewDbSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + constructor( public actionLabels: ActionLabelsI18n, private authStorageService: AuthStorageService, private orchService: OrchestratorService, private hostService: HostService, private router: Router, + private formatterService: FormatterService, private modalService: ModalService, private osdService: OsdService, private taskWrapper: TaskWrapperService @@ -114,10 +154,86 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { desc: $localize`Encryption` } }; - this.featureList = _.map(this.features, (o, key) => Object.assign(o, { key: key })); + this.featureList = _.map(this.features, (o, key) => Object.assign({}, o, { key })); this.createForm(); } + private getStepsForMode(mode: string): Array { + return mode !== 'manual' + ? [ + { label: STEP_LABELS.DEPLOYMENT, invalid: false }, + { label: STEP_LABELS.FEATURES, invalid: false }, + { label: STEP_LABELS.REVIEW, invalid: false } + ] + : [ + { label: STEP_LABELS.DEPLOYMENT, invalid: false }, + { label: STEP_LABELS.DATA, invalid: false }, + { label: STEP_LABELS.DB_WAL, invalid: false }, + { label: STEP_LABELS.FEATURES, invalid: false }, + { label: STEP_LABELS.REVIEW, invalid: false } + ]; + } + + private createEmptyReviewDeviceSelection(): ReviewDeviceSelection { + return { + count: 0, + capacity: '', + filters: [], + slots: null, + hasSelection: false + }; + } + + private formatHostPattern(pattern?: string): string { + if (!pattern || pattern === '*') { + return $localize`All hosts`; + } + + return pattern; + } + + private getReviewFilters(selectionGroup?: OsdDevicesSelectionGroupsComponent): ReviewField[] { + return (selectionGroup?.appliedFilters ?? []).map((filter) => ({ + label: filter.name, + value: filter.value?.formatted ?? filter.value?.raw ?? '-' + })); + } + + private buildReviewDeviceSelection( + selectionGroup?: OsdDevicesSelectionGroupsComponent, + slotControlName?: 'walSlots' | 'dbSlots' + ): ReviewDeviceSelection { + const devices = selectionGroup?.devices ?? []; + const totalCapacity = _.sumBy(devices, (device) => device?.sys_api?.size ?? 0); + + return { + count: devices.length, + capacity: + devices.length > 0 ? this.formatterService.formatToBinary(totalCapacity, false) : '', + filters: this.getReviewFilters(selectionGroup), + slots: + slotControlName && devices.length > 0 + ? Number(this.form.get(slotControlName)?.value ?? 0) + : null, + hasSelection: devices.length > 0 + }; + } + + private getEnabledFeatures(): string[] { + return this.featureList + .filter((feature) => this.form.get('features')?.get(feature.key)?.value) + .map((feature) => feature.desc); + } + + private getEncryptedFeatureValue(): boolean { + return this.form.get('features')?.get('encrypted')?.value ?? false; + } + + private updateSteps() { + const mode = this.form?.get('deploymentMode')?.value ?? 'automatic'; + this.steps = this.getStepsForMode(mode); + } + ngOnInit() { this.orchService.status().subscribe((status) => { this.hasOrchestrator = status.available; @@ -131,6 +247,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { this.osdService.getDeploymentOptions().subscribe((options) => { this.deploymentOptions = options; if (!this.osdService.selectedFormValues) { + this.form.get('deploymentMode').setValue('automatic', { emitEvent: false }); this.form.get('deploymentOption').setValue(this.deploymentOptions?.recommended_option); } @@ -142,23 +259,40 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { // restoring form value on back/next if (this.osdService.selectedFormValues) { this.form = _.cloneDeep(this.osdService.selectedFormValues); + if (!this.form.get('deploymentMode')) { + this.form.addControl('deploymentMode', new UntypedFormControl('automatic')); + } this.form .get('deploymentOption') .setValue(this.osdService.selectedFormValues.value?.deploymentOption); } this.simpleDeployment = this.osdService.isDeployementModeSimple; + this.form + .get('deploymentMode') + .setValue(this.simpleDeployment ? 'automatic' : 'manual', { emitEvent: false }); + this.updateSteps(); + this.form + .get('deploymentMode') + .valueChanges.subscribe((mode) => this.onDeploymentModeChanged(mode)); this.form.get('walSlots').valueChanges.subscribe((value) => this.setSlots('wal', value)); this.form.get('dbSlots').valueChanges.subscribe((value) => this.setSlots('db', value)); _.each(this.features, (feature) => { - this.form - .get('features') - .get(feature.key) - .valueChanges.subscribe((value) => this.featureFormUpdate(feature.key, value)); + const featureControl = this.form.get('features').get(feature.key ?? ''); + if (!featureControl) { + return; + } + + featureControl.valueChanges.subscribe((value) => + this.featureFormUpdate(feature.key ?? '', value) + ); }); + + this.populateReviewData(); } createForm() { this.form = new CdFormGroup({ + deploymentMode: new UntypedFormControl('automatic'), walSlots: new UntypedFormControl(0), dbSlots: new UntypedFormControl(0), features: new CdFormGroup( @@ -168,7 +302,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { return acc; }, {}) ), - deploymentOption: new UntypedFormControl(0) + deploymentOption: new UntypedFormControl(null) }); } @@ -193,22 +327,33 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { } if (slots >= 0) { this.driveGroup.setSlots(type, slots); + this.populateReviewData(); } } featureFormUpdate(key: string, checked: boolean) { this.driveGroup.setFeature(key, checked); + this.populateReviewData(); } enableFeatures() { this.featureList.forEach((feature) => { - this.form.get(feature.key).enable({ emitEvent: false }); + const control = this.form.get('features').get(feature.key); + if (!control) { + return; + } + + control.enable({ emitEvent: false }); }); } disableFeatures() { this.featureList.forEach((feature) => { - const control = this.form.get(feature.key); + const control = this.form.get('features').get(feature.key); + if (!control) { + return; + } + control.disable({ emitEvent: false }); control.setValue(false, { emitEvent: false }); }); @@ -233,6 +378,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { this.enableFeatures(); } this.driveGroup.setDeviceSelection(event.type, event.filters); + this.populateReviewData(); this.emitDriveGroup.emit(this.driveGroup); } @@ -253,28 +399,95 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { const slotControlName = `${event.type}Slots`; this.form.get(slotControlName).setValue(0, { emitEvent: false }); } + + this.populateReviewData(); } emitDeploymentSelection() { const option = this.form.get('deploymentOption').value; - const encrypted = this.form.get('encrypted').value; + const encrypted = this.getEncryptedFeatureValue(); this.emitDeploymentOption.emit({ option: option, encrypted: encrypted }); } - emitDeploymentMode() { - this.simpleDeployment = !this.simpleDeployment; - if (!this.simpleDeployment && this.dataDeviceSelectionGroups.devices.length === 0) { + onDeploymentModeChanged(mode: string) { + const deploymentMode = mode ?? this.form?.get('deploymentMode')?.value ?? 'automatic'; + this.simpleDeployment = deploymentMode !== 'manual'; + this.updateSteps(); + const hasDataDevices = (this.dataDeviceSelectionGroups?.devices?.length ?? 0) > 0; + if (!this.simpleDeployment && !hasDataDevices) { this.disableFeatures(); } else { this.enableFeatures(); } + this.populateReviewData(); this.emitMode.emit(this.simpleDeployment); } + populateReviewData() { + this.reviewDeploymentModeLabel = this.simpleDeployment + ? $localize`Automatic` + : $localize`Manual selection`; + + const selectedOption = this.form.get('deploymentOption')?.value as OsdDeploymentOptions; + const deploymentOption = this.deploymentOptions?.options?.[selectedOption]; + this.reviewDeploymentOptionTitle = deploymentOption?.title ?? ''; + this.reviewDeploymentOptionDescription = deploymentOption?.desc ?? ''; + this.reviewEnabledFeatures = this.getEnabledFeatures(); + + if (this.simpleDeployment) { + this.reviewHostPattern = ''; + this.reviewDataSelection = this.createEmptyReviewDeviceSelection(); + this.reviewWalSelection = this.createEmptyReviewDeviceSelection(); + this.reviewDbSelection = this.createEmptyReviewDeviceSelection(); + return; + } + + this.reviewHostPattern = this.formatHostPattern( + this.hostname || (this.driveGroup.spec['host_pattern'] as string) + ); + this.reviewDataSelection = this.buildReviewDeviceSelection(this.dataDeviceSelectionGroups); + this.reviewWalSelection = this.buildReviewDeviceSelection( + this.walDeviceSelectionGroups, + 'walSlots' + ); + this.reviewDbSelection = this.buildReviewDeviceSelection( + this.dbDeviceSelectionGroups, + 'dbSlots' + ); + } + + private navigateAfterCreate() { + const returnUrl = window.history.state?.returnUrl; + + if (this.osdCreated.observers.length > 0) { + this.osdCreated.emit(); + return; + } + + if (returnUrl === '/add-storage') { + this.router.navigate(['/add-storage']); + return; + } + + const hasSafeReturnUrl = + typeof returnUrl === 'string' && + returnUrl.startsWith('/') && + !returnUrl.startsWith('//') && + returnUrl !== '/osd/create'; + + if (hasSafeReturnUrl) { + this.router.navigateByUrl(returnUrl); + return; + } + + this.router.navigate(['/osd']); + } + submit() { if (this.simpleDeployment) { + this.isSubmitLoading = true; const option = this.form.get('deploymentOption').value; - const encrypted = this.form.get('encrypted').value; + const encrypted = this.getEncryptedFeatureValue(); const deploymentSpec = { option: option, encrypted: encrypted }; const title = this.deploymentOptions.options[deploymentSpec.option].title; const trackingId = `${title} deployment`; @@ -286,8 +499,12 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { call: this.osdService.create([deploymentSpec], trackingId, 'predefined') }) .subscribe({ + error: () => { + this.isSubmitLoading = false; + }, complete: () => { - this.router.navigate(['/osd']); + this.isSubmitLoading = false; + this.navigateAfterCreate(); } }); } else { @@ -298,14 +515,15 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { driveGroups: [this.driveGroup.spec] }); modalRef.componentInstance.submitAction.subscribe(() => { - this.router.navigate(['/osd']); + this.navigateAfterCreate(); }); + this.isSubmitLoading = false; this.previewButtonPanel.submitButton.loading = false; } } ngOnDestroy() { this.osdService.selectedFormValues = _.cloneDeep(this.form); - this.osdService.isDeployementModeSimple = this.dataDeviceSelectionGroups?.devices?.length === 0; + this.osdService.isDeployementModeSimple = this.simpleDeployment; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html index bbb6449fc6a..de9fd2a479d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html @@ -1,63 +1,74 @@ - +@if (showTabs) { + + +
+} @else { + +} (); + @ViewChild('osdUsageTpl', { static: true }) osdUsageTpl: TemplateRef; @ViewChild('markOsdConfirmationTpl', { static: true }) @@ -125,7 +136,17 @@ export class OsdListComponent extends ListWithDetails implements OnInit { name: this.actionLabels.CREATE, permission: 'create', icon: Icons.add, - click: () => this.router.navigate([this.urlBuilder.getCreate()]), + click: () => { + if (this.createAction.observers.length > 0) { + this.createAction.emit(); + } else { + this.router.navigate([this.urlBuilder.getCreate()], { + state: { + returnUrl: this.router.url + } + }); + } + }, disable: (selection: CdTableSelection) => this.getDisable('create', selection), canBePrimary: (selection: CdTableSelection) => !selection.hasSelection }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts index 567f9a3f013..dd91a045c95 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts @@ -1,4 +1,5 @@ import { + ChangeDetectorRef, Component, ContentChildren, EventEmitter, @@ -114,7 +115,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { } currentStep: number = 0; - lastStep: number = null; + lastStep: number | null = null; isOpen: boolean = true; hasModalOutlet: boolean = false; private destroy$ = new Subject(); @@ -124,7 +125,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { private cdsModalService: ModalCdsService, private route: ActivatedRoute, private location: Location, - private destroyRef: DestroyRef + private destroyRef: DestroyRef, + private cdr: ChangeDetectorRef ) {} ngOnInit() { @@ -163,6 +165,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { if (this.currentStep !== 0) { this.currentStep = this.currentStep - 1; this.stepChanged.emit({ current: this.currentStep }); + this.cdr.markForCheck(); } } @@ -177,6 +180,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) { this.currentStep = this.currentStep + 1; this.stepChanged.emit({ current: this.currentStep }); + this.cdr.markForCheck(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts new file mode 100644 index 00000000000..220214b4a62 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts @@ -0,0 +1,5 @@ +export enum OsdDeviceType { + DATA = 'data', + WAL = 'wal', + DB = 'db' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index 1580b6a7bd9..ac3a20cea99 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -9,6 +9,14 @@ padding-left: layout.$spacing-06; } +.cds-pl-4 { + padding-left: layout.$spacing-04; +} + +.cds-pl-7 { + padding-left: layout.$spacing-07; +} + .cds-pt-2px { padding-top: 2px; } From 15e47f930d52b2c6539fd39b04306577bf0f86df Mon Sep 17 00:00:00 2001 From: Syed Ali Ul Hasan Date: Sat, 6 Jun 2026 22:17:23 +0530 Subject: [PATCH 139/596] mgr/dashboard: migrated host table tabs to resource pages Fixes : https://tracker.ceph.com/issues/76712 Signed-off-by: Syed Ali Ul Hasan --- .../frontend/src/app/app-routing.module.ts | 36 +++++++++ .../src/app/ceph/cluster/cluster.module.ts | 2 + .../host-details-breadcrumb.resolver.ts | 17 +++++ .../host-details-section.component.html | 31 ++++++++ .../host-details-section.component.ts | 21 ++++++ .../host-details/host-details.component.html | 66 +--------------- .../host-details.component.spec.ts | 41 ++++++---- .../host-details/host-details.component.ts | 75 ++++++++++++++++--- .../ceph/cluster/hosts/hosts.component.html | 12 ++- .../app/ceph/cluster/hosts/hosts.component.ts | 1 + 10 files changed, 211 insertions(+), 91 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 0e41636c19d..5a77bb1f1db 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -8,7 +8,10 @@ import { ConfigurationFormComponent } from './ceph/cluster/configuration/configu import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component'; import { CreateClusterComponent } from './ceph/cluster/create-cluster/create-cluster.component'; import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component'; +import { HostDetailsComponent } from './ceph/cluster/hosts/host-details/host-details.component'; import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component'; +import { HostDetailsBreadcrumbResolver } from './ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver'; +import { HostDetailsSectionComponent } from './ceph/cluster/hosts/host-details/host-details-section.component'; import { HostsComponent } from './ceph/cluster/hosts/hosts.component'; import { InventoryComponent } from './ceph/cluster/inventory/inventory.component'; import { LogsComponent } from './ceph/cluster/logs/logs.component'; @@ -153,6 +156,39 @@ const routes: Routes = [ } ] }, + { + path: 'hosts/:hostname', + component: HostDetailsComponent, + data: { breadcrumbs: HostDetailsBreadcrumbResolver }, + children: [ + { path: '', redirectTo: 'devices', pathMatch: 'full' }, + { + path: 'devices', + component: HostDetailsSectionComponent, + data: { breadcrumbs: 'Devices', section: 'devices' } + }, + { + path: 'physical-disks', + component: HostDetailsSectionComponent, + data: { breadcrumbs: 'Physical Disks', section: 'physical-disks' } + }, + { + path: 'daemons', + component: HostDetailsSectionComponent, + data: { breadcrumbs: 'Daemons', section: 'daemons' } + }, + { + path: 'performance-details', + component: HostDetailsSectionComponent, + data: { breadcrumbs: 'Performance Details', section: 'performance-details' } + }, + { + path: 'device-health', + component: HostDetailsSectionComponent, + data: { breadcrumbs: 'Device health', section: 'device-health' } + } + ] + }, { path: 'ceph-users', component: CRUDTableComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index a711704ad6e..990bd0d1f15 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -60,6 +60,7 @@ import { CreateClusterStep4Component } from './create-cluster/create-cluster-ste import { CreateClusterComponent } from './create-cluster/create-cluster.component'; import { CrushmapComponent } from './crushmap/crushmap.component'; import { HostDetailsComponent } from './hosts/host-details/host-details.component'; +import { HostDetailsSectionComponent } from './hosts/host-details/host-details-section.component'; import { HostFormComponent } from './hosts/host-form/host-form.component'; import { HostsComponent } from './hosts/hosts.component'; import { InventoryDevicesComponent } from './inventory/inventory-devices/inventory-devices.component'; @@ -155,6 +156,7 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ OsdScrubModalComponent, OsdFlagsModalComponent, HostDetailsComponent, + HostDetailsSectionComponent, ConfigurationDetailsComponent, ConfigurationFormComponent, OsdReweightModalComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts new file mode 100644 index 00000000000..fe277471be4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Injectable({ + providedIn: 'root' +}) +export class HostDetailsBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const hostname = route.parent?.params?.hostname || route.params?.hostname || ''; + return [ + { text: 'Cluster/Hosts', path: '/hosts' }, + { text: hostname, path: this.getFullPath(route) } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html new file mode 100644 index 00000000000..1c91b7deead --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html @@ -0,0 +1,31 @@ +@if (hostname) { + @switch (section) { + @case ('devices') { + + } + @case ('physical-disks') { + + } + @case ('daemons') { + + + } + @case ('performance-details') { + + + } + @case ('device-health') { + + } + } +} @else { + No hostname found. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts new file mode 100644 index 00000000000..c4e92ddc853 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts @@ -0,0 +1,21 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; + +@Component({ + selector: 'cd-host-details-section', + templateUrl: './host-details-section.component.html', + standalone: false +}) +export class HostDetailsSectionComponent implements OnInit { + hostname = ''; + section = ''; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.route.parent?.paramMap.subscribe((pm: ParamMap) => { + this.hostname = pm.get('hostname') ?? ''; + }); + this.section = this.route.snapshot.data['section'] ?? ''; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html index e2c838a6083..35343d1f505 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html @@ -1,62 +1,4 @@ - - - -
-
- - - No hostname found. - + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts index 59c6666e374..f0086af4365 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts @@ -1,14 +1,17 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; import { CephModule } from '~/app/ceph/ceph.module'; import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module'; import { CoreModule } from '~/app/core/core.module'; import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { SharedModule } from '~/app/shared/shared.module'; -import { configureTestBed, TabHelper } from '~/testing/unit-test-helper'; +import { configureTestBed } from '~/testing/unit-test-helper'; import { HostDetailsComponent } from './host-details.component'; describe('HostDetailsComponent', () => { @@ -24,36 +27,44 @@ describe('HostDetailsComponent', () => { CoreModule, CephSharedModule, SharedModule + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ hostname: 'localhost' })) + } + }, + { + provide: AuthStorageService, + useValue: { + getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] }) + } + } ] }); beforeEach(() => { fixture = TestBed.createComponent(HostDetailsComponent); component = fixture.componentInstance; - component.selection = undefined; - component.permissions = new Permissions({ - hosts: ['read'], - grafana: ['read'] - }); }); it('should create', () => { expect(component).toBeTruthy(); }); - describe('Host details tabset', () => { + describe('Host resource layout', () => { beforeEach(() => { - component.selection = { hostname: 'localhost' }; fixture.detectChanges(); }); - it('should recognize a tabset child', () => { - const tabsetChild = TabHelper.getNgbNav(fixture); - expect(tabsetChild).toBeDefined(); + it('should render the sidebar layout', () => { + const layout = fixture.nativeElement.querySelector('cd-sidebar-layout'); + expect(layout).toBeTruthy(); }); - it('should show tabs', () => { - expect(TabHelper.getTextContents(fixture)).toEqual([ + it('should build the sidebar items', () => { + expect(component.sidebarItems.map((item) => item.label)).toEqual([ 'Devices', 'Physical Disks', 'Daemons', @@ -61,5 +72,9 @@ describe('HostDetailsComponent', () => { 'Device health' ]); }); + + it('should set the hostname title', () => { + expect(component.hostname).toBe('localhost'); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts index 9af6a87a52f..9384acdca68 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts @@ -1,21 +1,78 @@ -import { Component, Input } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; -import { Permissions } from '~/app/shared/models/permissions'; +import { Subscription } from 'rxjs'; + +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; @Component({ selector: 'cd-host-details', templateUrl: './host-details.component.html', styleUrls: ['./host-details.component.scss'], + encapsulation: ViewEncapsulation.None, standalone: false }) -export class HostDetailsComponent { - @Input() - permissions: Permissions; +export class HostDetailsComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + public readonly basePath = '/hosts'; + hostname = ''; + sidebarItems: SidebarItem[] = []; - @Input() - selection: any; + constructor(private route: ActivatedRoute, private authStorageService: AuthStorageService) {} - get selectedHostname(): string { - return this.selection !== undefined ? this.selection['hostname'] : null; + ngOnInit(): void { + const permissions = this.authStorageService.getPermissions(); + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.hostname = pm.get('hostname') ?? ''; + this.buildSidebarItems(permissions); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildSidebarItems(permissions: any): void { + const items: SidebarItem[] = [ + { + label: $localize`Devices`, + route: [this.basePath, this.hostname, 'devices'], + routerLinkActiveOptions: { exact: true } + } + ]; + + if (permissions.hosts?.read) { + items.push( + { + label: $localize`Physical Disks`, + route: [this.basePath, this.hostname, 'physical-disks'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Daemons`, + route: [this.basePath, this.hostname, 'daemons'], + routerLinkActiveOptions: { exact: true } + } + ); + } + + if (permissions.grafana?.read) { + items.push({ + label: $localize`Performance Details`, + route: [this.basePath, this.hostname, 'performance-details'], + routerLinkActiveOptions: { exact: true } + }); + } + + items.push({ + label: $localize`Device health`, + route: [this.basePath, this.hostname, 'device-health'], + routerLinkActiveOptions: { exact: true } + }); + + this.sidebarItems = items; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html index 9ea40658b1b..8f85391c666 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html @@ -12,11 +12,9 @@ (fetchData)="getHosts($event)" selectionType="single" [searchableObjects]="true" - [hasDetails]="hasTableDetails" [serverSide]="true" [count]="count" [maxLimit]="25" - (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" [toolHeader]="!hideToolHeader">
@@ -33,10 +31,6 @@ [tableActions]="expandClusterActions">
- -
@@ -73,7 +67,11 @@ - {{ row.hostname }} + + {{ row.hostname }} +
Date: Wed, 10 Jun 2026 12:41:21 -0700 Subject: [PATCH 140/596] qa: Fix task missing function call lambda was missing function call so always returned true Signed-off-by: Eric Zhang --- qa/tasks/stretch_cluster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qa/tasks/stretch_cluster.py b/qa/tasks/stretch_cluster.py index 0038b9d4dcb..086a72c8414 100644 --- a/qa/tasks/stretch_cluster.py +++ b/qa/tasks/stretch_cluster.py @@ -678,7 +678,7 @@ class TestStretchCluster(MgrTestCase): # Fail over osds in DC2 completely expects PGs to be 100% inactive self._fail_over_all_osds_in_dc('dc2') - self.wait_until_true_and_hold(lambda: self._pg_all_unavailable, + self.wait_until_true_and_hold(lambda: self._pg_all_unavailable(), timeout=self.RECOVERY_PERIOD, success_hold_time=self.SUCCESS_HOLD_TIME) From 473104c243860e36f1f12a27d09573b9f96f132a Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Wed, 7 Jan 2026 11:55:25 +0000 Subject: [PATCH 141/596] crimson/osd: Add functions to notify mon when PGs are ready to merge When a PG is in the pending merge state it is >= pg_num_pending and < pg_num. When this happens, IO is paused and once the PG peers we notify the mon that we are idle and safe to merge. Use Gated for merge notify callbacks. Signed-off-by: Aishwarya Mathuria Co-authored-by: Cursor --- src/crimson/osd/osd.cc | 4 + src/crimson/osd/pg.h | 57 ++++++++++-- src/crimson/osd/shard_services.cc | 145 ++++++++++++++++++++++++++++++ src/crimson/osd/shard_services.h | 28 ++++++ 4 files changed, 228 insertions(+), 6 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 0f94d16bdfc..8e6d048be1d 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -1330,6 +1330,10 @@ seastar::future<> OSD::committed_osd_maps( old_map = osdmap; } + // Drop merge-ready bookkeeping for PGs removed by the map we just + // consumed, mirroring classic OSD::consume_map(). + co_await get_shard_services().prune_sent_ready_to_merge(); + if (osdmap->is_up(whoami)) { const auto up_from = osdmap->get_up_from(whoami); INFO("osd.{}: map e {} marked me up: up_from {}, bind_epoch {}, state {}", diff --git a/src/crimson/osd/pg.h b/src/crimson/osd/pg.h index e24427a6a0d..f3913c919df 100644 --- a/src/crimson/osd/pg.h +++ b/src/crimson/osd/pg.h @@ -26,6 +26,7 @@ #include "osd/DynamicPerfStats.h" #include "crimson/common/interruptible_future.h" +#include "crimson/common/gated.h" #include "crimson/common/log.h" #include "crimson/common/type_helpers.h" #include "crimson/os/futurized_collection.h" @@ -563,12 +564,51 @@ public: std::pair do_delete_work(ceph::os::Transaction &t, ghobject_t _next) final; - // merge/split not ready - void clear_ready_to_merge() final {} - void set_not_ready_to_merge_target(pg_t pgid, pg_t src) final {} - void set_not_ready_to_merge_source(pg_t pgid) final {} - void set_ready_to_merge_target(eversion_t lu, epoch_t les, epoch_t lec) final {} - void set_ready_to_merge_source(eversion_t lu) final {} + void clear_ready_to_merge() final { + LOG_PREFIX(PG::clear_ready_to_merge); + SUBDEBUGDPP(osd, "", *this); + merge_notify_gate.dispatch_in_background( + "clear_ready_to_merge", *this, + [this] { + return shard_services.clear_ready_to_merge(pgid.pgid); + }); + } + void set_not_ready_to_merge_target(pg_t pgid, pg_t src) final { + LOG_PREFIX(PG::set_not_ready_to_merge_target); + SUBDEBUGDPP(osd, "", *this); + merge_notify_gate.dispatch_in_background( + "set_not_ready_to_merge_target", *this, + [this, pgid, src] { + return shard_services.set_not_ready_to_merge_target(pgid, src); + }); + } + void set_not_ready_to_merge_source(pg_t pgid) final { + LOG_PREFIX(PG::set_not_ready_to_merge_source); + SUBDEBUGDPP(osd, "", *this); + merge_notify_gate.dispatch_in_background( + "set_not_ready_to_merge_source", *this, + [this, pgid] { + return shard_services.set_not_ready_to_merge_source(pgid); + }); + } + void set_ready_to_merge_target(eversion_t lu, epoch_t les, epoch_t lec) final { + LOG_PREFIX(PG::set_ready_to_merge_target); + SUBDEBUGDPP(osd, "", *this); + merge_notify_gate.dispatch_in_background( + "set_ready_to_merge_target", *this, + [this, lu, les, lec] { + return shard_services.set_ready_to_merge_target(pgid.pgid, lu, les, lec); + }); + } + void set_ready_to_merge_source(eversion_t lu) final { + LOG_PREFIX(PG::set_ready_to_merge_source); + SUBDEBUGDPP(osd, "", *this); + merge_notify_gate.dispatch_in_background( + "set_ready_to_merge_source", *this, + [this, lu] { + return shard_services.set_ready_to_merge_source(pgid.pgid, lu); + }); + } void on_active_actmap() final; void on_active_advmap(const OSDMapRef &osdmap) final; @@ -1148,6 +1188,11 @@ private: // continuations here. bool stopping = false; + // PeeringListener merge callbacks must remain void, but they trigger async + // mon notifies in ShardServices. Gate them here so failures are logged and + // PG::stop() waits for them to drain. + crimson::common::Gated merge_notify_gate; + PGActivationBlocker wait_for_active_blocker; PglogBasedRecovery* pglog_based_recovery_op = nullptr; diff --git a/src/crimson/osd/shard_services.cc b/src/crimson/osd/shard_services.cc index 3780cd626c6..e4ff7d02a42 100644 --- a/src/crimson/osd/shard_services.cc +++ b/src/crimson/osd/shard_services.cc @@ -9,6 +9,7 @@ #include "messages/MOSDMap.h" #include "messages/MOSDPGCreated.h" #include "messages/MOSDPGTemp.h" +#include "messages/MOSDPGReadyToMerge.h" #include "osd/osd_perf_counters.h" #include "osd/PeeringState.h" @@ -308,6 +309,150 @@ void OSDSingletonState::prune_pg_created() } } +seastar::future<> OSDSingletonState::set_ready_to_merge_source(pg_t pgid, + eversion_t version) +{ + LOG_PREFIX(OSDSingletonState::set_ready_to_merge_source); + DEBUG("{}", pgid); + ready_to_merge_source[pgid] = version; + ceph_assert(!not_ready_to_merge_source.contains(pgid)); + return send_ready_to_merge(); +} + +seastar::future<> OSDSingletonState::set_ready_to_merge_target(pg_t pgid, + eversion_t version, + epoch_t last_epoch_started, + epoch_t last_epoch_clean) +{ + LOG_PREFIX(OSDSingletonState::set_ready_to_merge_target); + DEBUG("{}", pgid); + ready_to_merge_target.insert(std::make_pair(pgid, + std::make_tuple(version, + last_epoch_started, + last_epoch_clean))); + ceph_assert(!not_ready_to_merge_target.contains(pgid)); + return send_ready_to_merge(); +} + +seastar::future<> OSDSingletonState::set_not_ready_to_merge_source(pg_t source) +{ + LOG_PREFIX(OSDSingletonState::set_not_ready_to_merge_source); + DEBUG("{}", source); + not_ready_to_merge_source.insert(source); + ceph_assert(!ready_to_merge_source.contains(source)); + return send_ready_to_merge(); +} + +seastar::future<> OSDSingletonState::set_not_ready_to_merge_target(pg_t target, pg_t source) +{ + LOG_PREFIX(OSDSingletonState::set_not_ready_to_merge_target); + DEBUG("{} source {}", target, source); + not_ready_to_merge_target[target] = source; + ceph_assert(!ready_to_merge_target.contains(target)); + return send_ready_to_merge(); +} + +seastar::future<> OSDSingletonState::send_ready_to_merge() +{ + LOG_PREFIX(OSDSingletonState::send_ready_to_merge); + DEBUG(" ready_to_merge_source: {} not_ready_to_merge_source: {} \ + ready_to_merge_target: {} not_ready_to_merge_target: {} \ + sent_ready_to_merge_source {}", ready_to_merge_source, + not_ready_to_merge_source, ready_to_merge_target, not_ready_to_merge_target, + sent_ready_to_merge_source); + + struct ready_to_merge_send_t { + pg_t pgid; + eversion_t source_version; + eversion_t target_version; + epoch_t last_epoch_started = 0; + epoch_t last_epoch_clean = 0; + bool ready = false; + }; + + const epoch_t map_epoch = osdmap->get_epoch(); + std::vector pending; + pending.reserve(not_ready_to_merge_source.size() + + not_ready_to_merge_target.size() + + ready_to_merge_source.size()); + + for (auto src : not_ready_to_merge_source) { + if (!sent_ready_to_merge_source.contains(src)) { + sent_ready_to_merge_source.insert(src); + pending.push_back({src, {}, {}, 0, 0, false}); + } + } + for (auto p : not_ready_to_merge_target) { + if (!sent_ready_to_merge_source.contains(p.second)) { + sent_ready_to_merge_source.insert(p.second); + pending.push_back({p.second, {}, {}, 0, 0, false}); + } + } + for (auto& [src_pg, src_version] : ready_to_merge_source) { + if (not_ready_to_merge_source.contains(src_pg) || + not_ready_to_merge_target.contains(src_pg.get_parent())) { + continue; + } + auto p = ready_to_merge_target.find(src_pg.get_parent()); + if (p != ready_to_merge_target.end() && + !sent_ready_to_merge_source.contains(src_pg)) { + sent_ready_to_merge_source.insert(src_pg); + pending.push_back({ + src_pg, + src_version, + std::get<0>(p->second), + std::get<1>(p->second), + std::get<2>(p->second), + true}); + } + } + + if (pending.empty()) { + return seastar::now(); + } + return seastar::parallel_for_each( + std::move(pending), + [this, map_epoch](const ready_to_merge_send_t& m) { + return monc.send_message(crimson::make_message( + m.pgid, + m.source_version, + m.target_version, + m.last_epoch_started, + m.last_epoch_clean, + m.ready, + map_epoch)); + }); +} + +void OSDSingletonState::clear_ready_to_merge(pg_t pgid) +{ + ready_to_merge_source.erase(pgid); + ready_to_merge_target.erase(pgid); + not_ready_to_merge_source.erase(pgid); + not_ready_to_merge_target.erase(pgid); + sent_ready_to_merge_source.erase(pgid); +} + +void OSDSingletonState::clear_sent_ready_to_merge() +{ + sent_ready_to_merge_source.clear(); +} + +void OSDSingletonState::prune_sent_ready_to_merge() +{ + LOG_PREFIX(OSDSingletonState::prune_sent_ready_to_merge); + auto source = sent_ready_to_merge_source.begin(); + while (source != sent_ready_to_merge_source.end()) { + if (!osdmap->pg_exists(*source)) { + DEBUG("{}", *source); + source = sent_ready_to_merge_source.erase(source); + } else { + DEBUG(" exist {}", *source); + ++source; + } + } +} + seastar::future<> OSDSingletonState::send_alive(const epoch_t want) { LOG_PREFIX(OSDSingletonState::send_alive); diff --git a/src/crimson/osd/shard_services.h b/src/crimson/osd/shard_services.h index 19717ee55ef..7ab1eef6ce1 100644 --- a/src/crimson/osd/shard_services.h +++ b/src/crimson/osd/shard_services.h @@ -371,6 +371,25 @@ private: seastar::future<> store_maps(ceph::os::Transaction& t, epoch_t start, Ref m); void trim_maps(ceph::os::Transaction& t, OSDSuperblock& superblock); + + // -- PG merging -- + std::map ready_to_merge_source; + std::map> ready_to_merge_target; + std::set not_ready_to_merge_source; + std::map not_ready_to_merge_target; + std::set sent_ready_to_merge_source; + seastar::future<> set_ready_to_merge_source(pg_t pgid, + eversion_t version); + seastar::future<> set_ready_to_merge_target(pg_t pgid, + eversion_t version, + epoch_t last_epoch_started, + epoch_t last_epoch_clean); + seastar::future<> set_not_ready_to_merge_source(pg_t source); + seastar::future<> set_not_ready_to_merge_target(pg_t target, pg_t source); + void clear_ready_to_merge(pg_t pgid); + seastar::future<> send_ready_to_merge(); + void clear_sent_ready_to_merge(); + void prune_sent_ready_to_merge(); }; /** @@ -650,6 +669,15 @@ public: return local_state.ec_extent_cache_lru; } + FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_source) + FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_target) + FORWARD_TO_OSD_SINGLETON(set_not_ready_to_merge_source) + FORWARD_TO_OSD_SINGLETON(set_not_ready_to_merge_target) + FORWARD_TO_OSD_SINGLETON(clear_ready_to_merge) + FORWARD_TO_OSD_SINGLETON(send_ready_to_merge) + FORWARD_TO_OSD_SINGLETON(clear_sent_ready_to_merge) + FORWARD_TO_OSD_SINGLETON(prune_sent_ready_to_merge) + FORWARD_TO_OSD_SINGLETON(get_pool_info) FORWARD(get_throttle, get_throttle, local_state.throttler) From 7f970fcda01fd6c27b28f0c38ff9ffee241a0f04 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Thu, 8 Jan 2026 10:57:37 +0000 Subject: [PATCH 142/596] crimson/osd/shard_services: inherit from peering_sharded_service Update ShardServices to inherit from seastar::peering_sharded_service. This allows the service to access its own sharded container directly via container() rather than manually storing a reference to it. Signed-off-by: Aishwarya Mathuria --- src/crimson/osd/shard_services.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/crimson/osd/shard_services.h b/src/crimson/osd/shard_services.h index 7ab1eef6ce1..803ff092d79 100644 --- a/src/crimson/osd/shard_services.h +++ b/src/crimson/osd/shard_services.h @@ -395,7 +395,8 @@ private: /** * Represents services available to each PG */ -class ShardServices : public OSDMapService { +class ShardServices : public OSDMapService, + public seastar::peering_sharded_service { friend class PGShardManager; friend class OSD; using cached_map_t = OSDMapService::cached_map_t; From e7cf68899f6f98592eb25cf313319a50ff1a4bf3 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Thu, 8 Jan 2026 11:09:58 +0000 Subject: [PATCH 143/596] crimson/osd/pg: modify stop() function This function ensures that when a PG is being removed or merged and it calls stop() - it will clear primary state, and notify the Monitor to clear any pending merge flags. It will also call client_request_orderer.clear_and_cancel() ensuring all remaining client requests are properly completed. This is needed for merging in particular since on_change() is never called for the merge epoch (handle_advance_map is skipped after merge detection), so clear_and_cancel() is never invoked on the source PG's orderer. Signed-off-by: Aishwarya Mathuria --- src/crimson/osd/pg.cc | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/crimson/osd/pg.cc b/src/crimson/osd/pg.cc index d54cee09ae2..09919ab05f7 100644 --- a/src/crimson/osd/pg.cc +++ b/src/crimson/osd/pg.cc @@ -1683,20 +1683,26 @@ seastar::future<> PG::stop() { logger().info("PG {} {}", pgid, __func__); stopping = true; + + context_registry_on_change(); + clear_primary_state(); + if (is_primary()) { + clear_ready_to_merge(); + } + cancel_local_background_io_reservation(); cancel_remote_recovery_reservation(); check_readable_timer.cancel(); renew_lease_timer.cancel(); backend->on_actingset_changed(false); - return osdmap_gate.stop().then([this] { - return wait_for_active_blocker.stop(); - }).then([this] { - return recovery_handler->stop(); - }).then([this] { - return recovery_backend->stop(); - }).then([this] { - return backend->stop(); - }); + + co_await merge_notify_gate.close(); + co_await osdmap_gate.stop(); + co_await wait_for_active_blocker.stop(); + client_request_orderer.clear_and_cancel(*this); + co_await recovery_handler->stop(); + co_await recovery_backend->stop(); + co_await backend->stop(); } void PG::on_change(ceph::os::Transaction &t) { From e12454b5fedf880abee8364c5bad2e6aacc7b1cb Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Thu, 8 Jan 2026 16:44:01 +0000 Subject: [PATCH 144/596] crimson/osd: per-PG rendezvous for cross-shard merge source handoff Add infrastructure so source PGs can be extracted from their birth shard, moved to the target shard, and collected by the target PG before merge proceeds. Cross-shard safety: PGs are tied to their birth_shard for destruction. register_merge_source() uses extract_pg() to detach the source, seastar::foreign_ptr to hop cores, and crimson::local_shared_foreign_ptr on the target so release routes destruction back to the birth shard. Synchronization: replace the per-shard ShardServices merge_info_t registry (shared_promise waiters, ready_pgs staging, and cleanup hooks) with merge state on the target PG itself. Source-side register_merge_source() delivers PGs via PG::add_merge_source(); the target waits in PG::collect_merge_sources(n) on a per-PG semaphore. Duplicate source registrations are ignored. PG::stop() breaks the semaphore so shutdown does not hang. ShardServices::register_merge_source() and extract_pg() live in shard_services; rendezvous types and methods live on PG. Signed-off-by: Aishwarya Mathuria --- src/crimson/osd/pg.cc | 44 +++++++++++++++++++++++++++++++ src/crimson/osd/pg.h | 43 ++++++++++++++++++++++++++++++ src/crimson/osd/shard_services.cc | 41 ++++++++++++++++++++++++++++ src/crimson/osd/shard_services.h | 14 ++++++++++ 4 files changed, 142 insertions(+) diff --git a/src/crimson/osd/pg.cc b/src/crimson/osd/pg.cc index 09919ab05f7..837d0e0f9a3 100644 --- a/src/crimson/osd/pg.cc +++ b/src/crimson/osd/pg.cc @@ -1690,6 +1690,11 @@ seastar::future<> PG::stop() clear_ready_to_merge(); } + // Wake any coroutine parked in collect_merge_sources() so shutdown + // doesn't hang waiting for sources that will never arrive. + merge_rendezvous.arrivals.broken(); + merge_rendezvous.sources.clear(); + cancel_local_background_io_reservation(); cancel_remote_recovery_reservation(); check_readable_timer.cancel(); @@ -2037,4 +2042,43 @@ void PG::PGLogEntryHandler::partial_write(pg_info_t *info, __func__, info->partial_writes_last_complete); } +void PG::add_merge_source( + spg_t source, + core_id_t birth_shard, + seastar::foreign_ptr> source_pg) +{ + LOG_PREFIX(PG::add_merge_source); + auto wrapped = + crimson::make_local_shared_foreign>(std::move(source_pg)); + auto [_, inserted] = merge_rendezvous.sources.emplace( + source, std::make_pair(birth_shard, std::move(wrapped))); + if (inserted) { + DEBUG("target {} source {} arrived from shard {} ({} total)", + get_pgid(), source, birth_shard, + merge_rendezvous.sources.size()); + merge_rendezvous.arrivals.signal(1); + } else { + DEBUG("target {} source {} already registered, ignoring", + get_pgid(), source); + } +} + +seastar::future +PG::collect_merge_sources(std::size_t n) +{ + LOG_PREFIX(PG::collect_merge_sources); + DEBUG("target {} waiting for {} sources ({} already arrived)", + get_pgid(), n, merge_rendezvous.sources.size()); + try { + co_await merge_rendezvous.arrivals.wait(n); + } catch (const seastar::broken_semaphore&) { + DEBUG("target {} merge rendezvous broken, aborting wait", get_pgid()); + co_return merge_source_map_t{}; + } + ceph_assert(merge_rendezvous.sources.size() == n); + auto sources = std::move(merge_rendezvous.sources); + merge_rendezvous.sources.clear(); + co_return sources; +} + } diff --git a/src/crimson/osd/pg.h b/src/crimson/osd/pg.h index f3913c919df..6e5e47c591c 100644 --- a/src/crimson/osd/pg.h +++ b/src/crimson/osd/pg.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include "common/dout.h" #include "common/ostream_temp.h" @@ -564,6 +566,38 @@ public: std::pair do_delete_work(ceph::os::Transaction &t, ghobject_t _next) final; + // Per-PG rendezvous used to collect source PGs converging on this PG + // during a merge. Producers (source-side coroutines on other shards) + // push entries via add_merge_source(); the target-side coroutine waits + // via collect_merge_sources(n). + using merge_source_entry_t = + std::pair>>; + using merge_source_map_t = std::map; + + // Producer side: called on the target's shard with the foreign PG + // already wrapped. The first registration for a given source signals + // the semaphore; duplicate registrations (e.g. from replay) are no-ops. + void add_merge_source( + spg_t source, + core_id_t birth_shard, + seastar::foreign_ptr> source_pg); + + // Consumer side: wait until `n` distinct sources have arrived, then + // return them and clear the rendezvous state. Returns an empty map if + // reset_merge_rendezvous() breaks the wait (e.g. PG stop or merge cancel). + seastar::future collect_merge_sources(std::size_t n); + + // Drop in-flight handoffs and reset the semaphore. Call on PG stop or + // after Seastore cross-shard cancel so a failed try cannot leave stale + // sources for the next epoch. + void reset_merge_rendezvous(); + + void merge_from( + merge_source_map_t& sources, + PeeringCtx &rctx, + unsigned split_bits, + const pg_merge_meta_t& last_pg_merge_meta); + void clear_ready_to_merge() final { LOG_PREFIX(PG::clear_ready_to_merge); SUBDEBUGDPP(osd, "", *this); @@ -1188,6 +1222,15 @@ private: // continuations here. bool stopping = false; + // Rendezvous state owned by the target PG of a pending merge. + // sources is keyed by source pgid so re-registration is naturally + // idempotent; arrivals is signaled exactly once per first insert. + struct merge_rendezvous_t { + merge_source_map_t sources; + seastar::semaphore arrivals{0}; + }; + merge_rendezvous_t merge_rendezvous; + // PeeringListener merge callbacks must remain void, but they trigger async // mon notifies in ShardServices. Gate them here so failures are logged and // PG::stop() waits for them to drain. diff --git a/src/crimson/osd/shard_services.cc b/src/crimson/osd/shard_services.cc index e4ff7d02a42..06a55649d3b 100644 --- a/src/crimson/osd/shard_services.cc +++ b/src/crimson/osd/shard_services.cc @@ -107,6 +107,47 @@ seastar::future<> PerShardState::broadcast_map_to_pgs( }); } +seastar::future> ShardServices::extract_pg(spg_t pgid) { + auto pg = local_state.pg_map.get_pg(pgid); + ceph_assert(pg); + co_await remove_pg(pgid); + co_return pg; +} + +seastar::future<> ShardServices::register_merge_source( + spg_t target, + spg_t source) +{ + LOG_PREFIX(ShardServices::register_merge_source); + + core_id_t birth_shard = seastar::this_shard_id(); + core_id_t target_core = co_await get_pg_mapping(target); + + // Remove the source pg from pg_to_shard_mapping so that + // it no longer receives any messages + auto pg_to_move = co_await extract_pg(source); + + // Wrap the PG in a foreign_ptr to cross cores safely. If the target + // happens to be on the same shard, invoke_on simply runs the lambda + // inline. + auto foreign_pg = seastar::make_foreign(std::move(pg_to_move)); + + DEBUG("Target {} on shard {} (from shard {}); handing off source {}", + target, target_core, birth_shard, source); + + co_await container().invoke_on( + target_core, + [target, source, birth_shard, foreign_pg = std::move(foreign_pg)] + (ShardServices& target_svc) mutable { + auto target_pg = target_svc.local_state.pg_map.get_pg(target); + // PGAdvanceMap on the target shard is expected to have instantiated + // the target PG by this point in the merge protocol. + ceph_assert(target_pg); + target_pg->add_merge_source( + source, birth_shard, std::move(foreign_pg)); + }); +} + Ref PerShardState::get_pg(spg_t pgid) { assert_core(); diff --git a/src/crimson/osd/shard_services.h b/src/crimson/osd/shard_services.h index 803ff092d79..80c26e8d240 100644 --- a/src/crimson/osd/shard_services.h +++ b/src/crimson/osd/shard_services.h @@ -546,6 +546,13 @@ public: return pg_to_shard_mapping.get_or_create_pg_mapping(pgid, core, store_index); } + seastar::future get_pg_mapping(spg_t pgid) { + return pg_to_shard_mapping.get_or_create_pg_mapping(pgid).then( + [](std::pair mapping) { + return mapping.first; + }); + } + auto remove_pg(spg_t pgid) { local_state.pg_map.remove_pg(pgid); return pg_to_shard_mapping.remove_pg_mapping(pgid); @@ -670,6 +677,13 @@ public: return local_state.ec_extent_cache_lru; } + seastar::future> extract_pg(spg_t pgid); + // Hand the source PG off to the target PG's rendezvous, hopping shards + // if needed. The consumer side (target PG) waits via + // PG::collect_merge_sources(); cleanup happens when the target drops the + // local_shared_foreign_ptr it received. + seastar::future<> register_merge_source(spg_t target, spg_t source); + FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_source) FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_target) FORWARD_TO_OSD_SINGLETON(set_not_ready_to_merge_source) From 7f7439cc81ba1b849c928474dc1d62244823450f Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Fri, 9 Jan 2026 07:20:44 +0000 Subject: [PATCH 145/596] crimson/osd/pg: implement PG::merge_from Add PG::merge_from to execute the merge of source PGs into a target PG. This function builds a transaction to remove source-specifc metadata objects and merge source collections into the target collection. Signed-off-by: Aishwarya Mathuria --- src/crimson/osd/pg.cc | 38 ++++++++++++++++++++++++++++++++++++++ src/crimson/osd/pg.h | 5 +++++ 2 files changed, 43 insertions(+) diff --git a/src/crimson/osd/pg.cc b/src/crimson/osd/pg.cc index 837d0e0f9a3..a9506994971 100644 --- a/src/crimson/osd/pg.cc +++ b/src/crimson/osd/pg.cc @@ -2081,4 +2081,42 @@ PG::collect_merge_sources(std::size_t n) co_return sources; } +void PG::merge_from( + merge_source_map_t& sources, + PeeringCtx &rctx, + unsigned split_bits, + const pg_merge_meta_t& last_pg_merge_meta) +{ + LOG_PREFIX(PG::merge_from); + DEBUG("target {}", get_pgid()); + + std::map source_states; + for (auto& [pgid, entry] : sources) { + auto& src_pg = entry.second; + source_states.emplace(pgid, &src_pg->peering_state); + } + + // Updates the target's PeeringState and stats (Synchronous) + peering_state.merge_from(source_states, rctx, split_bits, last_pg_merge_meta); + + // We iterate through each source and move its objects into the target collection + for (auto& [pgid, entry] : sources) { + auto& src_pg = entry.second; + auto src_coll = src_pg->get_collection_ref()->get_cid(); + auto dst_coll = coll_ref->get_cid(); + DEBUG("merging source {}", pgid); + + // Remove source-specific metadata objects that are no longer needed + // now that the collections are being collapsed. + rctx.transaction.remove(src_coll, src_pg->get_pgid().make_snapmapper_oid()); + rctx.transaction.remove(src_coll, src_pg->pgmeta_oid); + // Move source collection into target collection. + rctx.transaction.merge_collection(src_coll, dst_coll, split_bits); + } + + // Adjust the collection and snap_mapper to reflect the + // new, smaller PG count (reducing bitmask). + rctx.transaction.collection_set_bits(coll_ref->get_cid(), split_bits); + snap_mapper.update_bits(split_bits); +} } diff --git a/src/crimson/osd/pg.h b/src/crimson/osd/pg.h index 6e5e47c591c..2e8238fcaf8 100644 --- a/src/crimson/osd/pg.h +++ b/src/crimson/osd/pg.h @@ -586,6 +586,11 @@ public: // return them and clear the rendezvous state. Returns an empty map if // reset_merge_rendezvous() breaks the wait (e.g. PG stop or merge cancel). seastar::future collect_merge_sources(std::size_t n); + void merge_from( + merge_source_map_t& sources, + PeeringCtx &rctx, + unsigned split_bits, + const pg_merge_meta_t& last_pg_merge_meta); // Drop in-flight handoffs and reset the semaphore. Call on PG stop or // after Seastore cross-shard cancel so a failed try cannot leave stale From 7c033ce1990937a850d3a8f07b063bd7da198cea Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Fri, 9 Jan 2026 10:48:16 +0000 Subject: [PATCH 146/596] crimson/osd: implement PG merge detection and orchestration in PGAdvanceMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate PG merge handling into the map advancement pipeline. When pg_num shrinks between epochs, check_for_merges() returns a merge_result_t describing whether this PG is a merge source, target, or not involved. start() stops advancing through later epochs once a merge is detected, then either finish_merge_advance() or the normal activate path runs so complete_rctx() always happens in one place. - check_for_merges(): detect pg_num shrink and dispatch merge_pg(). - merge_pg(): merge-only work — Seastore eligibility, source handoff setup, target rendezvous collection and PG::merge_from(). - finish_merge_advance(): commit rctx and complete the role-specific steps (source: complete_rctx, stop, register_merge_source; target: handle_advance_map, handle_activate_map, complete_rctx). Signed-off-by: Aishwarya Mathuria --- .../osd/osd_operations/pg_advance_map.cc | 112 ++++++++++++++++-- .../osd/osd_operations/pg_advance_map.h | 22 ++++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src/crimson/osd/osd_operations/pg_advance_map.cc b/src/crimson/osd/osd_operations/pg_advance_map.cc index d4cc12847e2..ff412adc5db 100644 --- a/src/crimson/osd/osd_operations/pg_advance_map.cc +++ b/src/crimson/osd/osd_operations/pg_advance_map.cc @@ -82,20 +82,39 @@ seastar::future<> PGAdvanceMap::start() pg->handle_initialize(rctx); pg->handle_activate_map(rctx); } - for (epoch_t next_epoch = from + 1; next_epoch <= to; next_epoch++) { - DEBUG("{}: start: getting map {}", - *this, next_epoch); + ceph_assert(std::cmp_less_equal(from, to)); + + for (epoch_t current_epoch = from; current_epoch < to; ++current_epoch) { + epoch_t next_epoch = current_epoch + 1; + DEBUG("{}: start: getting map {}", *this, next_epoch); cached_map_t next_map = co_await shard_services.get_map(next_epoch); - DEBUG("{}: advancing map to {}", - *this, next_map->get_epoch()); - // Use the current OSDMap epoch to check for splits consecutively. - epoch_t current_epoch = pg->get_osdmap_epoch(); + merge_result_t merge_result = + co_await check_for_merges(current_epoch, next_map, rctx); + if (merge_result.role == merge_role_t::Source) { + // Merge source: hand the PG off to the target and finish. The PG + // stops existing locally, so there is nothing left to advance here. + DEBUG("{}: merge source, handing off after e{}", *this, current_epoch); + co_await finish_merge_source(merge_result.parent, rctx); + co_await handle.complete(); + exit_handle.cancel(); + co_return; + } + // A successful merge target (merge already applied by check_for_merges) + // and the no-merge case both advance this epoch and keep iterating, so + // the PG advances all the way to `to`, matching the classic OSD instead + // of stalling at an intermediate epoch. + DEBUG("{}: advancing map to {}", *this, next_map->get_epoch()); + // Baseline epoch for split detection: PG map epoch before advancing to next_map. + epoch_t split_epoch = pg->get_osdmap_epoch(); pg->handle_advance_map(next_map, rctx); DEBUG("{}: checking for splits between {} and {}", - *this, current_epoch, next_map->get_epoch()); - co_await check_for_splits(current_epoch, std::move(next_map)); + *this, split_epoch, next_map->get_epoch()); + co_await check_for_splits(split_epoch, std::move(next_map)); } + // Normal completion, which also covers a completed merge target: the PG was + // fully advanced to `to` in the loop above, so just activate the final map + // and commit. pg->handle_activate_map(rctx); DEBUG("{}: map activated", *this); if (do_init) { @@ -140,7 +159,6 @@ seastar::future<> PGAdvanceMap::check_for_splits( } } - seastar::future<> PGAdvanceMap::split_pg( std::set split_children, cached_map_t next_map) @@ -236,5 +254,79 @@ void PGAdvanceMap::split_stats(std::set> children_pgs, pg->finish_split_stats(*stat_iter, rctx.transaction); } +seastar::future PGAdvanceMap::check_for_merges( + epoch_t old_epoch, + cached_map_t next_map, + PeeringCtx &rctx) +{ + LOG_PREFIX(PGAdvanceMap::check_for_merges); + using cached_map_t = OSDMapService::cached_map_t; + cached_map_t old_map = co_await shard_services.get_map(old_epoch); + if (!old_map->have_pg_pool(pg->get_pgid().pool())) { + DEBUG("{} pool doesn't exist in epoch {}", pg->get_pgid(), + old_epoch); + co_return merge_result_t{}; + } + auto old_pg_num = old_map->get_pg_num(pg->get_pgid().pool()); + if (!next_map->have_pg_pool(pg->get_pgid().pool())) { + DEBUG("{} pool doesn't exist in epoch {}", pg->get_pgid(), + next_map->get_epoch()); + co_return merge_result_t{}; + } + auto new_pg_num = next_map->get_pg_num(pg->get_pgid().pool()); + DEBUG("{} pg_num change in e{} {} -> {}", pg->get_pgid(), next_map->get_epoch(), + old_pg_num, new_pg_num); + if (new_pg_num && new_pg_num < old_pg_num) { + co_return co_await merge_pg(next_map, new_pg_num, old_pg_num, rctx); + } + co_return merge_result_t{}; +} + +seastar::future PGAdvanceMap::merge_pg( + cached_map_t next_map, + unsigned new_pg_num, + unsigned old_pg_num, + PeeringCtx &rctx) +{ + LOG_PREFIX(PGAdvanceMap::merge_pg); + DEBUG("{}: start", *this); + spg_t parent; + std::set merge_sources; + if (pg->pgid.is_merge_source(old_pg_num, + new_pg_num, + &parent)) { + co_return merge_result_t{merge_role_t::Source, parent}; + } else if (pg->pgid.is_merge_target(old_pg_num, + new_pg_num)) { + DEBUG("Target PG {} identified. Waiting for sources...", pg->get_pgid()); + pg->pgid.is_split(new_pg_num, old_pg_num, &merge_sources); + // Block until all source PGs (potentially from other shards) arrive + // on this PG's rendezvous + auto sources = co_await pg->collect_merge_sources(merge_sources.size()); + if (sources.empty()) { + co_return merge_result_t{}; + } + + unsigned split_bits = pg->get_pgid().get_split_bits(new_pg_num); + const auto& merge_meta = + next_map->get_pg_pool(pg->get_pgid().pool())->last_pg_merge_meta; + pg->merge_from(sources, rctx, split_bits, merge_meta); + + co_return merge_result_t{merge_role_t::Target}; + } else { + co_return merge_result_t{}; + } +} + +seastar::future<> PGAdvanceMap::finish_merge_source( + spg_t parent, + PeeringCtx &rctx) +{ + LOG_PREFIX(PGAdvanceMap::finish_merge_source); + DEBUG("{}: committing rctx before handoff to target {}", *this, parent); + co_await pg->complete_rctx(std::move(rctx)); + co_await pg->stop(); + co_await shard_services.register_merge_source(parent, pg->get_pgid()); +} } diff --git a/src/crimson/osd/osd_operations/pg_advance_map.h b/src/crimson/osd/osd_operations/pg_advance_map.h index 6dbb4a974c2..07e625e69a4 100644 --- a/src/crimson/osd/osd_operations/pg_advance_map.h +++ b/src/crimson/osd/osd_operations/pg_advance_map.h @@ -52,8 +52,23 @@ public: PipelineHandle &get_handle() { return handle; } using cached_map_t = OSDMapService::cached_map_t; + + enum class merge_role_t { + None, + Source, + Target, + }; + + struct merge_result_t { + merge_role_t role = merge_role_t::None; + spg_t parent; + }; + seastar::future<> check_for_splits(epoch_t old_epoch, cached_map_t next_map); + seastar::future check_for_merges(epoch_t old_epoch, + cached_map_t next_map, + PeeringCtx &rctx); seastar::future<> split_pg(std::set split_children, cached_map_t next_map); void split_stats(std::set> child_pgs, @@ -69,10 +84,17 @@ public: } private: + seastar::future merge_pg(cached_map_t next_map, + unsigned new_pg_num, + unsigned old_pg_num, + PeeringCtx &rctx); PGPeeringPipeline &peering_pp(PG &pg); seastar::future> handle_split_pg_creation( spg_t child_pgid, cached_map_t next_map); + seastar::future<> finish_merge_source( + spg_t parent, + PeeringCtx &rctx); }; } From 0aa3da13cd76a7075811f665dd7ed83a4a485d48 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Mon, 19 Jan 2026 12:01:22 +0530 Subject: [PATCH 147/596] doc/dev/crimson: Add explanation for PG merging Signed-off-by: Aishwarya Mathuria --- doc/dev/crimson/pgmerging.rst | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 doc/dev/crimson/pgmerging.rst diff --git a/doc/dev/crimson/pgmerging.rst b/doc/dev/crimson/pgmerging.rst new file mode 100644 index 00000000000..bb516145384 --- /dev/null +++ b/doc/dev/crimson/pgmerging.rst @@ -0,0 +1,106 @@ +=========================================== +PG Merge Synchronization in Crimson +=========================================== + +This document describes the infrastructure used to coordinate the merging +of Placement Groups (PGs) in Crimson. + +Background +---------- +Crimson utilizes a shared-nothing memory model where each PG is owned by +a specific CPU core. Merging requires cross-core coordination while +maintaining memory safety and epoch consistency. + +Core Concepts +------------- + +.. _migration_safety: + +Migration Safety (Memory Ownership) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +PGs are managed by their ``birth_shard``. To prevent cross-shard +deallocation crashes: + +1. **Extraction**: ``ShardServices::extract_pg()`` removes the source PG + from its shard map so it stops receiving messages. +2. **Transportation**: The PG crosses cores in a ``seastar::foreign_ptr``. +3. **Storage**: The target shard stores each source in + ``crimson::local_shared_foreign_ptr>`` inside the target PG's + rendezvous map. When the target drops the last reference after + ``PG::merge_from()``, destruction is routed back to the source + ``birth_shard``. + +.. _synchronization: + +Target and Source PG Synchronization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Merging involves a race between source PGs checking in and the target PG +reaching the merge point in ``PGAdvanceMap``. + +Synchronization state lives on the **target PG**, not in a per-shard +registry: + +* **``merge_rendezvous_t``**: A map of arrived sources plus a + ``seastar::semaphore`` counting first-time registrations. +* **``PG::add_merge_source()``**: Called on the target's shard when + ``ShardServices::register_merge_source()`` delivers a source PG. + Duplicate source pgids are ignored (idempotent under replay). +* **``PG::collect_merge_sources(n)``**: Waits on the rendezvous semaphore + for ``n`` arrivals (one signal per first insert), asserts the map has + ``n`` entries, then returns sources and clears the rendezvous. If + ``reset_merge_rendezvous()`` breaks the wait, returns an empty map. +* **``PG::reset_merge_rendezvous()``**: Clears in-flight handoffs and + resets the semaphore. Called on ``PG::stop()`` or after a Seastore + cross-shard abort so a failed try cannot leave stale sources for a + later epoch. + +**Cross-shard handoff**: ``register_merge_source()`` resolves the target +shard, extracts the source locally, and uses ``invoke_on`` to call +``add_merge_source()`` on the target PG. + +.. _map_consistency: + +Map Advancement and Pipeline Stalling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To ensure the merge occurs between PGs at the same logical time: + +* ``PGAdvanceMap::check_for_merges()`` detects ``pg_num`` shrink between + map epochs and returns a ``merge_result_t`` (source, target, or none). +* ``merge_pg()`` performs merge-specific work only (Seastore eligibility, + rendezvous collection, ``PG::merge_from()`` on the target). +* A single ``PGAdvanceMap`` operation may batch multiple map epochs + (``from`` through ``to``). Only a **merge source** stops the epoch + loop early: ``finish_merge_source()`` commits ``rctx``, stops the PG, + and calls ``register_merge_source()``. A **merge target** runs + ``merge_from()`` at the merge epoch, then continues advancing through + the remaining epochs up to ``to`` (matching classic OSD behavior). + Normal completion at the end of ``start()`` activates the final map + and commits ``rctx`` (also covering a completed merge target). +* The target does not call ``merge_from()`` until sources frozen at the + merge epoch have arrived on its rendezvous. + +.. _seastore_cross_shard: + +Seastore Cross-Shard Merges +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Seastore does not support merging collections across reactor shards. +Before a source hands off or a target collects sources, ``merge_pg()`` +calls ``ShardServices::seastore_merge_shards_ok()`` with the full sibling +set. If the target and any source map to different shards, the OSD aborts +the attempt: clears ready-to-merge state, sends +``MOSDPGReadyToMerge{ ready=false }`` for the source so the monitor does +not commit the unsafe ``pg_num`` decrement, resets the target rendezvous, +and sends ``MOSDPGStopMerge`` to the monitor (once per pool per OSD). +The monitor clamps ``pg_num_target`` to ``pg_num`` and clears +``FLAG_CRIMSON_ALLOW_PG_MERGE`` for that pool until operators re-enable +merging (``crimson_allow_pg_merge``). + +Cleanup +------- +After ``complete_rctx()``, the target releases its ``local_shared_foreign_ptr`` +references; source PG objects are destroyed on their ``birth_shard``. + +When the OSD finishes consuming a new OSDMap in ``OSD::committed_osd_maps()``, +``OSDSingletonState::prune_sent_ready_to_merge()`` drops ``sent_ready_to_merge_source`` +entries (and stale ``pools_merge_stopped_reported`` pool ids) for PGs that no +longer exist in the committed map, mirroring classic ``OSD::consume_map()``. From 1f1db7d0cd91ce9d34986d05c3c457f873efb3c6 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Tue, 28 Apr 2026 12:11:22 +0530 Subject: [PATCH 148/596] mon/OSDMonitor: introduce per-pool crimson_allow_pg_merge flag Switch Crimson PG merge gating from a global config to a pool-scoped flag. Signed-off-by: Aishwarya Mathuria --- src/mon/MonCommands.h | 2 ++ src/mon/OSDMonitor.cc | 53 +++++++++++++++++++++++++++++++------------ src/osd/osd_types.h | 6 +++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/mon/MonCommands.h b/src/mon/MonCommands.h index 3c7f13db567..3024667cf67 100644 --- a/src/mon/MonCommands.h +++ b/src/mon/MonCommands.h @@ -1184,6 +1184,7 @@ COMMAND("osd pool get " "|compression_min_blob_size" "|compression_mode" "|compression_required_ratio" + "|crimson_allow_pg_merge" "|crush_rule" "|csum_max_block" "|csum_min_block" @@ -1250,6 +1251,7 @@ COMMAND("osd pool set " "|compression_min_blob_size" "|compression_mode" "|compression_required_ratio" + "|crimson_allow_pg_merge" "|crush_rule" "|csum_max_block" "|csum_min_block" diff --git a/src/mon/OSDMonitor.cc b/src/mon/OSDMonitor.cc index c27879fcd44..b256899e3a4 100644 --- a/src/mon/OSDMonitor.cc +++ b/src/mon/OSDMonitor.cc @@ -4078,7 +4078,19 @@ bool OSDMonitor::prepare_pg_ready_to_merge(MonOpRequestRef op) return false; /* nothing to propose, yet */ } - if (m->ready) { + bool allow_merge = true; + if (m->ready && p.has_flag(pg_pool_t::FLAG_CRIMSON)) { + if (!p.has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + allow_merge = false; + mon.clog->warn() << "blocking crimson pg merge for " << m->pgid + << " (pool '" << osdmap.get_pool_name(m->pgid.pool()) + << "') because pool flag 'crimson_allow_pg_merge' is not set"; + dout(1) << __func__ << " blocking crimson pg merge for " << m->pgid + << " because pool flag crimson_allow_pg_merge is not set" << dendl; + } + } + + if (m->ready && allow_merge) { p.dec_pg_num(m->pgid, pending_inc.epoch, m->source_version, @@ -8742,11 +8754,14 @@ int OSDMonitor::prepare_command_pool_set(const cmdmap_t& cmdmap, return -EPERM; } // check for Crimson pools - // pg merging is not yet supported in Crimson + // pg merging is only supported when explicitly enabled per-pool (crimson_allow_pg_merge) if (p.has_flag(pg_pool_t::FLAG_CRIMSON)) { if (n < (int)p.get_pg_num()) { - ss << "crimson-osd does not support decreasing pg_num_actual (shrinking)"; - return -ENOTSUP; + if (!p.has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + ss << "crimson-osd does not support decreasing pg_num_actual (shrinking) " + << "unless the pool flag crimson_allow_pg_merge is set"; + return -ENOTSUP; + } } if (n > (int)p.get_pg_num() && !g_conf().get_val("crimson_allow_pg_split")) { ss << "crimson_allow_pg_split is false; pg_num_actual increase denied"; @@ -8805,11 +8820,14 @@ int OSDMonitor::prepare_command_pool_set(const cmdmap_t& cmdmap, return -EPERM; } // check for Crimson pools - // pg merging is not yet supported in Crimson + // pg merging is only supported when explicitly enabled per-pool (crimson_allow_pg_merge) if (p.has_flag(pg_pool_t::FLAG_CRIMSON)) { if (n < (int)p.get_pg_num_target()) { - ss << "crimson-osd does not support decreasing pg_num"; - return -ENOTSUP; + if (!p.has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + ss << "crimson-osd does not support decreasing pg_num " + << "unless the pool flag crimson_allow_pg_merge is set"; + return -ENOTSUP; + } } if (n > (int)p.get_pg_num_target() && !g_conf().get_val("crimson_allow_pg_split")) { ss << "crimson_allow_pg_split is false; pg_num increase denied for crimson pool"; @@ -8886,11 +8904,14 @@ int OSDMonitor::prepare_command_pool_set(const cmdmap_t& cmdmap, return -EPERM; } // check for Crimson pools - // pg merging is not yet supported in Crimson + // pg merging is only supported when explicitly enabled per-pool (crimson_allow_pg_merge) if (p.has_flag(pg_pool_t::FLAG_CRIMSON)) { if (n < (int)p.get_pgp_num()) { - ss << "crimson-osd does not support decreasing pgp_num_actual"; - return -ENOTSUP; + if (!p.has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + ss << "crimson-osd does not support decreasing pgp_num_actual " + << "unless the pool flag crimson_allow_pg_merge is set"; + return -ENOTSUP; + } } if (n > (int)p.get_pgp_num() && !g_conf().get_val("crimson_allow_pg_split")) { ss << "crimson_allow_pg_split is false; pgp_num_actual increase denied"; @@ -8921,11 +8942,14 @@ int OSDMonitor::prepare_command_pool_set(const cmdmap_t& cmdmap, return -EPERM; } // check for Crimson pools - // pg merging is not yet supported in Crimson + // pg merging is only supported when explicitly enabled per-pool (crimson_allow_pg_merge) if (p.has_flag(pg_pool_t::FLAG_CRIMSON)) { if (n < (int)p.get_pgp_num_target()) { - ss << "crimson-osd does not support decreasing pgp_num"; - return -ENOTSUP; + if (!p.has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + ss << "crimson-osd does not support decreasing pgp_num " + << "unless the pool flag crimson_allow_pg_merge is set"; + return -ENOTSUP; + } } if (n > (int)p.get_pgp_num_target() && !g_conf().get_val("crimson_allow_pg_split")) { ss << "crimson_allow_pg_split is false; pgp_num increase denied"; @@ -8978,7 +9002,8 @@ int OSDMonitor::prepare_command_pool_set(const cmdmap_t& cmdmap, p.crush_rule = id; } else if (var == "nodelete" || var == "nopgchange" || var == "nosizechange" || var == "write_fadvise_dontneed" || - var == "noscrub" || var == "nodeep-scrub" || var == "bulk") { + var == "noscrub" || var == "nodeep-scrub" || var == "bulk" || + var == "crimson_allow_pg_merge") { uint64_t flag = pg_pool_t::get_flag_by_name(var); // make sure we only compare against 'n' if we didn't receive a string if (val == "true" || (interr.empty() && n == 1)) { diff --git a/src/osd/osd_types.h b/src/osd/osd_types.h index bb6cc381fee..1065066948e 100644 --- a/src/osd/osd_types.h +++ b/src/osd/osd_types.h @@ -1330,6 +1330,9 @@ struct pg_pool_t { FLAG_EC_OPTIMIZATIONS = 1<<19, // enable optimizations, once enabled, cannot be disabled FLAG_CLIENT_SPLIT_READS = 1<<20, // Optimized EC is permitted to do direct reads. FLAG_OMAP = 1<<21, // Pool is permitted to perform OMAP operations + // Allow decreasing pg_num/pgp_num (PG merge) for crimson pools. + // Note: requires that the pool is currently all bluestore. + FLAG_CRIMSON_ALLOW_PG_MERGE = 1<<22, }; static const char *get_flag_name(uint64_t f) { @@ -1356,6 +1359,7 @@ struct pg_pool_t { case FLAG_EC_OPTIMIZATIONS: return "ec_optimizations"; case FLAG_CLIENT_SPLIT_READS: return "split_reads"; case FLAG_OMAP: return "supports_omap"; + case FLAG_CRIMSON_ALLOW_PG_MERGE: return "crimson_allow_pg_merge"; default: return "???"; } } @@ -1412,6 +1416,8 @@ struct pg_pool_t { return FLAG_BULK; if (name == "crimson") return FLAG_CRIMSON; + if (name == "crimson_allow_pg_merge") + return FLAG_CRIMSON_ALLOW_PG_MERGE; if (name == "ec_optimizations") return FLAG_EC_OPTIMIZATIONS; if (name == "split_reads") From 4e44d8a1607087502c97e87e6b65e892fc161f15 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Tue, 13 Jan 2026 07:59:31 +0000 Subject: [PATCH 149/596] qa/suites/crimson: Add a test for PG merging in the crimson suite Signed-off-by: Aishwarya Mathuria --- qa/objectstore_crimson/crimson_bluestore.yaml | 2 + .../singleton/all/osd-pg-merging.yaml | 30 ++++++ qa/workunits/rados/test_pg_merging.sh | 97 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 qa/suites/crimson-rados/singleton/all/osd-pg-merging.yaml create mode 100755 qa/workunits/rados/test_pg_merging.sh diff --git a/qa/objectstore_crimson/crimson_bluestore.yaml b/qa/objectstore_crimson/crimson_bluestore.yaml index ff9bcab187d..1c5895deed0 100644 --- a/qa/objectstore_crimson/crimson_bluestore.yaml +++ b/qa/objectstore_crimson/crimson_bluestore.yaml @@ -2,6 +2,8 @@ overrides: ceph: fs: xfs conf: + global: + crimson_allow_pg_merge: true osd: # crimson's osd objectstore option osd objectstore: bluestore diff --git a/qa/suites/crimson-rados/singleton/all/osd-pg-merging.yaml b/qa/suites/crimson-rados/singleton/all/osd-pg-merging.yaml new file mode 100644 index 00000000000..9f4e1a2dd97 --- /dev/null +++ b/qa/suites/crimson-rados/singleton/all/osd-pg-merging.yaml @@ -0,0 +1,30 @@ +roles: +- - mon.a + - mon.b + - mon.c + - mgr.x + - osd.0 + - osd.1 + - osd.2 + - client.0 +openstack: + - volumes: # attached to each instance + count: 3 + size: 10 # GB +tasks: +- install: +- ceph: + pre-mgr-commands: + - sudo ceph config set mgr mgr_pool false --force + log-ignorelist: + - but it is still running + - overall HEALTH_ + - \(PG_ + conf: + osd: + osd min pg log entries: 5 + crimson cpu num: 2 +- workunit: + clients: + client.0: + - rados/test_pg_merging.sh diff --git a/qa/workunits/rados/test_pg_merging.sh b/qa/workunits/rados/test_pg_merging.sh new file mode 100755 index 00000000000..1441511bebb --- /dev/null +++ b/qa/workunits/rados/test_pg_merging.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +. $(dirname $0)/../../standalone/ceph-helpers.sh + +set -x + +function wait_for_merge_completion() { + local pool=$1 + local target=$2 + local stall_timeout=60 + local actual=0 + local last_actual=0 + local last_progress_time + + wait_for_clean || return 1 + last_actual=$(ceph pg ls-by-pool $pool --format=json 2>/dev/null | jq -r ".pg_stats | length") + last_progress_time=$(date +%s) + + echo "Waiting for pool $pool to reach $target PGs..." + + while true; do + actual=$last_actual + echo "Current PG count: $actual (Target: $target)" + if [ "$actual" -eq "$target" ]; then + break + fi + + sleep 2 + wait_for_clean || return 1 + actual=$(ceph pg ls-by-pool $pool --format=json 2>/dev/null | jq -r ".pg_stats | length") + + if [ "$actual" -lt "$last_actual" ]; then + last_progress_time=$(date +%s) + fi + last_actual=$actual + + if [ $(( $(date +%s) - last_progress_time )) -ge "$stall_timeout" ]; then + echo "PG merge made no progress for ${stall_timeout}s in pool $pool (still at $actual PGs)" + return 1 + fi + done +} + +function test_pg_merging() { + local pool="merge_pool" + ceph osd pool delete $pool $pool --yes-i-really-really-mean-it || true + create_pool $pool 16 + wait_for_clean || return 1 + + ceph osd pool set $pool nopgchange 0 + ceph osd pool set $pool crimson_allow_pg_merge true + + # Induce merges + ceph osd pool set $pool pg_num 4 + ceph osd pool set $pool pg_num_min 4 + + wait_for_merge_completion $pool 4 || return 1 + + ceph osd pool set $pool pgp_num 4 + wait_for_clean || return 1 +} + +function test_pg_merging_with_radosbench() { + local pool="merge_bench" + ceph osd pool delete $pool $pool --yes-i-really-really-mean-it || true + create_pool $pool 16 + wait_for_clean || return 1 + + # Start radosbench writes + timeout 120 rados bench -p $pool 60 write -b 4096 --no-cleanup & + BENCH_PID=$! + sleep 5 + + # merge + ceph osd pool set $pool nopgchange 0 + ceph osd pool set $pool crimson_allow_pg_merge true + ceph osd pool set $pool pg_num 4 + ceph osd pool set $pool pg_num_min 4 + + # Use the loop to ensure we don't stop until all 12 PGs are merged away + wait_for_merge_completion $pool 4 || return 1 + + ceph osd pool set $pool pgp_num 4 + wait_for_clean || return 1 + + # Ensure the bench finished successfully + wait $BENCH_PID + + # Final check + actual=$(ceph pg ls-by-pool $pool --format=json 2>/dev/null | jq -r ".pg_stats | length") + test "$actual" -eq 4 || return 1 +} + +test_pg_merging || { echo "test_pg_merging failed"; exit 1; } +test_pg_merging_with_radosbench || { echo "test_pg_merging_with_radosbench failed"; exit 1; } + +echo "OK" From f94860bd17ec6dfba8e1a69b68c3a99c03ba5101 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Mon, 18 May 2026 13:37:26 +0000 Subject: [PATCH 150/596] crimson/osd: reject Seastore PG merges across shards Seastore cannot merge collections between reactor shards currently. On cross-shard detection, tell the monitor the source PG is not ready (via MOSDPGReadyToMerge{ ready=false }) so the unsafe pg_num decrement is never proposed, then send MOSDPGStopMerge to clamp pg_num_target and permanently disable further shrink for the pool. Signed-off-by: Aishwarya Mathuria --- .../osd/osd_operations/pg_advance_map.cc | 10 ++ src/crimson/osd/pg.cc | 13 +- src/crimson/osd/pg.h | 5 - src/crimson/osd/shard_services.cc | 119 ++++++++++++++++++ src/crimson/osd/shard_services.h | 20 +++ src/messages/MOSDPGStopMerge.h | 53 ++++++++ src/mon/Monitor.cc | 1 + src/mon/OSDMonitor.cc | 88 +++++++++++++ src/mon/OSDMonitor.h | 3 + src/msg/Message.cc | 4 + src/msg/Message.h | 1 + src/msg/MessageRef.h | 1 + 12 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 src/messages/MOSDPGStopMerge.h diff --git a/src/crimson/osd/osd_operations/pg_advance_map.cc b/src/crimson/osd/osd_operations/pg_advance_map.cc index ff412adc5db..5ba9dc4dff5 100644 --- a/src/crimson/osd/osd_operations/pg_advance_map.cc +++ b/src/crimson/osd/osd_operations/pg_advance_map.cc @@ -295,11 +295,21 @@ seastar::future PGAdvanceMap::merge_pg( if (pg->pgid.is_merge_source(old_pg_num, new_pg_num, &parent)) { + parent.is_split(new_pg_num, old_pg_num, &merge_sources); + if (!co_await shard_services.seastore_merge_shards_ok( + parent, merge_sources)) { + co_return merge_result_t{}; + } co_return merge_result_t{merge_role_t::Source, parent}; } else if (pg->pgid.is_merge_target(old_pg_num, new_pg_num)) { DEBUG("Target PG {} identified. Waiting for sources...", pg->get_pgid()); pg->pgid.is_split(new_pg_num, old_pg_num, &merge_sources); + + if (!co_await shard_services.seastore_merge_shards_ok( + pg->get_pgid(), merge_sources)) { + co_return merge_result_t{}; + } // Block until all source PGs (potentially from other shards) arrive // on this PG's rendezvous auto sources = co_await pg->collect_merge_sources(merge_sources.size()); diff --git a/src/crimson/osd/pg.cc b/src/crimson/osd/pg.cc index a9506994971..f79e8272d99 100644 --- a/src/crimson/osd/pg.cc +++ b/src/crimson/osd/pg.cc @@ -1690,10 +1690,7 @@ seastar::future<> PG::stop() clear_ready_to_merge(); } - // Wake any coroutine parked in collect_merge_sources() so shutdown - // doesn't hang waiting for sources that will never arrive. - merge_rendezvous.arrivals.broken(); - merge_rendezvous.sources.clear(); + reset_merge_rendezvous(); cancel_local_background_io_reservation(); cancel_remote_recovery_reservation(); @@ -2081,6 +2078,14 @@ PG::collect_merge_sources(std::size_t n) co_return sources; } +void PG::reset_merge_rendezvous() +{ + // Unblock any waiter in collect_merge_sources() with broken(); then + // replace the semaphore so the next merge attempt starts at zero signals. + merge_rendezvous.arrivals.broken(); + merge_rendezvous = merge_rendezvous_t{}; +} + void PG::merge_from( merge_source_map_t& sources, PeeringCtx &rctx, diff --git a/src/crimson/osd/pg.h b/src/crimson/osd/pg.h index 2e8238fcaf8..6e5e47c591c 100644 --- a/src/crimson/osd/pg.h +++ b/src/crimson/osd/pg.h @@ -586,11 +586,6 @@ public: // return them and clear the rendezvous state. Returns an empty map if // reset_merge_rendezvous() breaks the wait (e.g. PG stop or merge cancel). seastar::future collect_merge_sources(std::size_t n); - void merge_from( - merge_source_map_t& sources, - PeeringCtx &rctx, - unsigned split_bits, - const pg_merge_meta_t& last_pg_merge_meta); // Drop in-flight handoffs and reset the semaphore. Call on PG stop or // after Seastore cross-shard cancel so a failed try cannot leave stale diff --git a/src/crimson/osd/shard_services.cc b/src/crimson/osd/shard_services.cc index 06a55649d3b..00b5dbabe0e 100644 --- a/src/crimson/osd/shard_services.cc +++ b/src/crimson/osd/shard_services.cc @@ -10,6 +10,7 @@ #include "messages/MOSDPGCreated.h" #include "messages/MOSDPGTemp.h" #include "messages/MOSDPGReadyToMerge.h" +#include "messages/MOSDPGStopMerge.h" #include "osd/osd_perf_counters.h" #include "osd/PeeringState.h" @@ -148,6 +149,84 @@ seastar::future<> ShardServices::register_merge_source( }); } +bool ShardServices::is_seastore_objectstore() +{ + return crimson::common::local_conf().get_val( + "osd_objectstore") == "seastore"; +} + +seastar::future<> ShardServices::reset_target_merge_rendezvous(spg_t target) +{ + const core_id_t target_core = co_await get_pg_mapping(target); + co_await container().invoke_on( + target_core, + [target](ShardServices& target_svc) { + if (auto target_pg = target_svc.local_state.pg_map.get_pg(target)) { + target_pg->reset_merge_rendezvous(); + } + }); +} + +seastar::future<> ShardServices::send_stop_pool_merge(pg_t source_pgid) +{ + co_await with_singleton( + [pool = source_pgid.pool(), source_pgid](OSDSingletonState& singleton) { + return singleton.send_stop_pool_pg_merge(pool, source_pgid); + }); +} + +seastar::future<> ShardServices::abort_seastore_cross_shard_merge( + spg_t target, + pg_t source_pgid, + const std::set* all_sources, + bool reset_target_rendezvous) +{ + if (all_sources) { + for (const auto& src : *all_sources) { + co_await clear_ready_to_merge(src.pgid); + } + } else { + co_await clear_ready_to_merge(source_pgid); + } + co_await clear_ready_to_merge(target.pgid); + + // Ensure the monitor backs off before it can commit a pg_num decrement + // for this source. "Stop merge" is permanent policy; "not ready" prevents + // an already-in-flight decrement decision from being committed. + co_await set_not_ready_to_merge_source(source_pgid); + co_await send_stop_pool_merge(source_pgid); + if (reset_target_rendezvous) { + co_await reset_target_merge_rendezvous(target); + } +} + + +seastar::future ShardServices::seastore_merge_shards_ok( + spg_t target, + const std::set& merge_sources) +{ + LOG_PREFIX(ShardServices::seastore_merge_shards_ok); + if (!is_seastore_objectstore()) { + co_return true; + } + const core_id_t target_core = co_await get_pg_mapping(target); + std::optional cross_shard_source; + for (const auto& src : merge_sources) { + if (co_await get_pg_mapping(src) != target_core) { + cross_shard_source = src.pgid; + break; + } + } + if (!cross_shard_source) { + co_return true; + } + DEBUG("seastore: target {} has a cross-shard merge source {}; stop pool merge", + target, *cross_shard_source); + co_await abort_seastore_cross_shard_merge( + target, *cross_shard_source, &merge_sources, true); + co_return false; +} + Ref PerShardState::get_pg(spg_t pgid) { assert_core(); @@ -479,9 +558,49 @@ void OSDSingletonState::clear_sent_ready_to_merge() sent_ready_to_merge_source.clear(); } +seastar::future<> OSDSingletonState::send_stop_pool_pg_merge(int64_t pool, pg_t pgid) +{ + LOG_PREFIX(OSDSingletonState::send_stop_pool_pg_merge); + const pg_pool_t *pi = osdmap->get_pg_pool(pool); + if (!pi || !pi->is_crimson()) { + co_return; + } + if (!pi->has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + pools_merge_stopped_reported.insert(pool); + co_return; + } + // Claim the pool before co_await: otherwise concurrent callers can all pass + // the check and each send MOSDPGStopMerge while yielded on send_message(). + if (!pools_merge_stopped_reported.insert(pool).second) { + co_return; + } + DEBUG("seastore: asking monitor to stop PG merge for pool {} (pg {})", + pool, pgid); + co_await monc.send_message(crimson::make_message( + pool, pgid, MOSDPGStopMerge::REASON_CROSS_SHARD, osdmap->get_epoch())); +} + +void OSDSingletonState::prune_pools_merge_stopped_reported() +{ + // send_stop_pool_pg_merge() inserts a pool after one MOSDPGStopMerge so we + // do not spam the mon on every cross-shard source/target pair. Keep that + // entry while the map still has merge disabled; forget it if the pool + // disappears or crimson_allow_pg_merge is set again. + auto pool = pools_merge_stopped_reported.begin(); + while (pool != pools_merge_stopped_reported.end()) { + const pg_pool_t *pi = osdmap->get_pg_pool(*pool); + if (!pi || pi->has_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE)) { + pool = pools_merge_stopped_reported.erase(pool); + } else { + ++pool; + } + } +} + void OSDSingletonState::prune_sent_ready_to_merge() { LOG_PREFIX(OSDSingletonState::prune_sent_ready_to_merge); + prune_pools_merge_stopped_reported(); auto source = sent_ready_to_merge_source.begin(); while (source != sent_ready_to_merge_source.end()) { if (!osdmap->pg_exists(*source)) { diff --git a/src/crimson/osd/shard_services.h b/src/crimson/osd/shard_services.h index 80c26e8d240..430f41502bf 100644 --- a/src/crimson/osd/shard_services.h +++ b/src/crimson/osd/shard_services.h @@ -378,6 +378,7 @@ private: std::set not_ready_to_merge_source; std::map not_ready_to_merge_target; std::set sent_ready_to_merge_source; + std::set pools_merge_stopped_reported; seastar::future<> set_ready_to_merge_source(pg_t pgid, eversion_t version); seastar::future<> set_ready_to_merge_target(pg_t pgid, @@ -388,7 +389,9 @@ private: seastar::future<> set_not_ready_to_merge_target(pg_t target, pg_t source); void clear_ready_to_merge(pg_t pgid); seastar::future<> send_ready_to_merge(); + seastar::future<> send_stop_pool_pg_merge(int64_t pool, pg_t pgid); void clear_sent_ready_to_merge(); + void prune_pools_merge_stopped_reported(); void prune_sent_ready_to_merge(); }; @@ -684,6 +687,14 @@ public: // local_shared_foreign_ptr it received. seastar::future<> register_merge_source(spg_t target, spg_t source); + static bool is_seastore_objectstore(); + + // True if every merge source is co-located on the target PG's shard + // (Seastore only). Uses the full sibling set so one source cannot commit + // while another would abort. + seastar::future seastore_merge_shards_ok( + spg_t target, const std::set& merge_sources); + FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_source) FORWARD_TO_OSD_SINGLETON(set_ready_to_merge_target) FORWARD_TO_OSD_SINGLETON(set_not_ready_to_merge_source) @@ -854,6 +865,15 @@ public: invoke_context_on_core(seastar::this_shard_id(), on_reserved)); } +private: + seastar::future<> reset_target_merge_rendezvous(spg_t target); + seastar::future<> send_stop_pool_merge(pg_t source_pgid); + seastar::future<> abort_seastore_cross_shard_merge( + spg_t target, + pg_t source_pgid, + const std::set* all_sources, + bool reset_target_rendezvous); + #undef FORWARD_CONST #undef FORWARD #undef FORWARD_TO_OSD_SINGLETON diff --git a/src/messages/MOSDPGStopMerge.h b/src/messages/MOSDPGStopMerge.h new file mode 100644 index 00000000000..fa0fa5ce446 --- /dev/null +++ b/src/messages/MOSDPGStopMerge.h @@ -0,0 +1,53 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab + +#pragma once + +#include "osd/osd_types.h" +#include "messages/PaxosServiceMessage.h" + +/// OSD -> mon: permanently stop PG merge shrink for a Crimson pool. +class MOSDPGStopMerge : public PaxosServiceMessage { +public: + static constexpr uint8_t REASON_CROSS_SHARD = 1; + + int64_t pool = -1; + pg_t pgid; + uint8_t reason = REASON_CROSS_SHARD; + + MOSDPGStopMerge() + : PaxosServiceMessage{MSG_OSD_PG_STOP_MERGE, 0} + {} + MOSDPGStopMerge(int64_t pool, pg_t pgid, uint8_t reason, epoch_t epoch) + : PaxosServiceMessage{MSG_OSD_PG_STOP_MERGE, epoch}, + pool(pool), + pgid(pgid), + reason(reason) + {} + void encode_payload(uint64_t features) override { + using ceph::encode; + paxos_encode(); + encode(pool, payload); + encode(pgid, payload); + encode(reason, payload); + } + void decode_payload() override { + using ceph::decode; + auto p = payload.cbegin(); + paxos_decode(p); + decode(pool, p); + decode(pgid, p); + decode(reason, p); + } + std::string_view get_type_name() const override { return "osd_pg_stop_merge"; } + void print(std::ostream &out) const { + out << get_type_name() + << "(pool " << pool + << " pg " << pgid + << " reason " << (unsigned)reason + << " v" << version << ")"; + } +private: + template + friend boost::intrusive_ptr ceph::make_message(Args&&... args); +}; diff --git a/src/mon/Monitor.cc b/src/mon/Monitor.cc index 80bca9ca4b9..205fb23c7ce 100644 --- a/src/mon/Monitor.cc +++ b/src/mon/Monitor.cc @@ -4782,6 +4782,7 @@ void Monitor::dispatch_op(MonOpRequestRef op) case MSG_REMOVE_SNAPS: case MSG_MON_GET_PURGED_SNAPS: case MSG_OSD_PG_READY_TO_MERGE: + case MSG_OSD_PG_STOP_MERGE: paxos_service[PAXOS_OSDMAP]->dispatch(op); return; diff --git a/src/mon/OSDMonitor.cc b/src/mon/OSDMonitor.cc index b256899e3a4..8121ea2523c 100644 --- a/src/mon/OSDMonitor.cc +++ b/src/mon/OSDMonitor.cc @@ -54,6 +54,7 @@ #include "messages/MOSDPGCreated.h" #include "messages/MOSDPGTemp.h" #include "messages/MOSDPGReadyToMerge.h" +#include "messages/MOSDPGStopMerge.h" #include "messages/MMonCommand.h" #include "messages/MRemoveSnaps.h" #include "messages/MRoute.h" @@ -2771,6 +2772,8 @@ bool OSDMonitor::preprocess_query(MonOpRequestRef op) return preprocess_pg_created(op); case MSG_OSD_PG_READY_TO_MERGE: return preprocess_pg_ready_to_merge(op); + case MSG_OSD_PG_STOP_MERGE: + return preprocess_pg_stop_merge(op); case MSG_OSD_PGTEMP: return preprocess_pgtemp(op); case MSG_OSD_BEACON: @@ -2817,6 +2820,8 @@ bool OSDMonitor::prepare_update(MonOpRequestRef op) return prepare_pgtemp(op); case MSG_OSD_PG_READY_TO_MERGE: return prepare_pg_ready_to_merge(op); + case MSG_OSD_PG_STOP_MERGE: + return prepare_pg_stop_merge(op); case MSG_OSD_BEACON: return prepare_beacon(op); @@ -4100,6 +4105,16 @@ bool OSDMonitor::prepare_pg_ready_to_merge(MonOpRequestRef op) p.last_change = pending_inc.epoch; } else { // back off the merge attempt! + if (!m->ready && !p.has_flag(pg_pool_t::FLAG_CRIMSON)) { + mon.clog->warn() << "osd." << m->get_orig_source().num() + << " reported pg " << m->pgid + << " not ready to merge; backing off pg_num decrease" + << " for pool '" + << osdmap.get_pool_name(m->pgid.pool()) << "'"; + dout(1) << __func__ << " osd." << m->get_orig_source().num() + << " pg " << m->pgid << " not ready to merge, backing off" + << dendl; + } p.set_pg_num_pending(p.get_pg_num()); } @@ -4129,6 +4144,79 @@ bool OSDMonitor::prepare_pg_ready_to_merge(MonOpRequestRef op) return true; } +bool OSDMonitor::preprocess_pg_stop_merge(MonOpRequestRef op) +{ + op->mark_osdmon_event(__func__); + auto m = op->get_req(); + dout(10) << __func__ << " " << *m << dendl; + auto session = op->get_session(); + if (!session) { + dout(10) << __func__ << ": no monitor session!" << dendl; + goto ignore; + } + if (!session->is_capable("osd", MON_CAP_X)) { + derr << __func__ << " received from entity " + << "with insufficient privileges " << session->caps << dendl; + goto ignore; + } + if (!osdmap.get_pg_pool(m->pool)) { + derr << __func__ << " pool " << m->pool << " dne" << dendl; + goto ignore; + } + return false; + + ignore: + mon.no_reply(op); + return true; +} + +bool OSDMonitor::prepare_pg_stop_merge(MonOpRequestRef op) +{ + op->mark_osdmon_event(__func__); + auto m = op->get_req(); + dout(10) << __func__ << " " << *m << dendl; + + pg_pool_t p; + if (pending_inc.new_pools.count(m->pool)) + p = pending_inc.new_pools[m->pool]; + else + p = *osdmap.get_pg_pool(m->pool); + + if (!p.is_crimson()) { + dout(10) << __func__ << " pool " << m->pool << " is not crimson, ignoring" + << dendl; + wait_for_finished_proposal(op, new C_ReplyMap(this, op, m->version)); + return false; + } + + const char *reason_str = "unknown"; + if (m->reason == MOSDPGStopMerge::REASON_CROSS_SHARD) { + reason_str = "cross-shard PG merge not supported on Seastore"; + } + + mon.clog->warn() << "osd." << m->get_orig_source().num() + << " stopped PG merge for pool '" + << osdmap.get_pool_name(m->pool) + << "' (" << reason_str << ", source pg " << m->pgid + << "); no further pg_num decrease will be attempted"; + + // Cancel any in-flight shrink; keep current pg_num as-is. + p.set_pg_num_pending(p.get_pg_num()); + if (p.get_pg_num_target() < p.get_pg_num()) { + p.set_pg_num_target(p.get_pg_num()); + } + if (p.get_pgp_num_target() < p.get_pgp_num()) { + p.set_pgp_num_target(p.get_pgp_num()); + } + p.unset_flag(pg_pool_t::FLAG_CRIMSON_ALLOW_PG_MERGE); + p.last_pg_merge_meta = pg_merge_meta_t{}; + p.last_change = pending_inc.epoch; + p.last_force_op_resend_prenautilus = pending_inc.epoch; + + pending_inc.new_pools[m->pool] = p; + wait_for_finished_proposal(op, new C_ReplyMap(this, op, m->version)); + return true; +} // ------------- // pg_temp changes diff --git a/src/mon/OSDMonitor.h b/src/mon/OSDMonitor.h index d0209fbc522..9000fc46de4 100644 --- a/src/mon/OSDMonitor.h +++ b/src/mon/OSDMonitor.h @@ -470,6 +470,9 @@ private: bool preprocess_pg_ready_to_merge(MonOpRequestRef op); bool prepare_pg_ready_to_merge(MonOpRequestRef op); + bool preprocess_pg_stop_merge(MonOpRequestRef op); + bool prepare_pg_stop_merge(MonOpRequestRef op); + int _check_remove_pool(int64_t pool_id, const pg_pool_t &pool, std::ostream *ss); bool _check_become_tier( int64_t tier_pool_id, const pg_pool_t *tier_pool, diff --git a/src/msg/Message.cc b/src/msg/Message.cc index e6600cda451..bbfa159345f 100644 --- a/src/msg/Message.cc +++ b/src/msg/Message.cc @@ -101,6 +101,7 @@ #include "messages/MOSDPGRecoveryDelete.h" #include "messages/MOSDPGRecoveryDeleteReply.h" #include "messages/MOSDPGReadyToMerge.h" +#include "messages/MOSDPGStopMerge.h" #include "messages/MRemoveSnaps.h" @@ -647,6 +648,9 @@ Message *decode_message(CephContext *cct, case MSG_OSD_PG_READY_TO_MERGE: m = make_message(); break; + case MSG_OSD_PG_STOP_MERGE: + m = make_message(); + break; case MSG_OSD_EC_WRITE: m = make_message(); break; diff --git a/src/msg/Message.h b/src/msg/Message.h index ec90687072e..071e6010381 100644 --- a/src/msg/Message.h +++ b/src/msg/Message.h @@ -152,6 +152,7 @@ #define MSG_OSD_SCRUB2 121 #define MSG_OSD_PG_READY_TO_MERGE 122 +#define MSG_OSD_PG_STOP_MERGE 124 #define MSG_OSD_PG_LEASE 133 #define MSG_OSD_PG_LEASE_ACK 134 diff --git a/src/msg/MessageRef.h b/src/msg/MessageRef.h index 416b58f8df7..c8f9f81aceb 100644 --- a/src/msg/MessageRef.h +++ b/src/msg/MessageRef.h @@ -157,6 +157,7 @@ class MOSDPGPush; class MOSDPGPushReply; class MOSDPGQuery; class MOSDPGReadyToMerge; +class MOSDPGStopMerge; class MOSDPGRecoveryDelete; class MOSDPGRecoveryDeleteReply; class MOSDPGRemove; From 50bf8ce2eb0ed6bd2c083169d6746baf3c0a8354 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Thu, 4 Jun 2026 15:25:45 +0530 Subject: [PATCH 151/596] mgr/dashboard: rbd-mirroring - hide create/import token buttons for read-only users Fixes: https://tracker.ceph.com/issues/77115 Signed-off-by: Aashish Sharma --- .../overview/overview.component.html | 31 +++++++++++-------- .../mirroring/overview/overview.component.ts | 2 ++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html index 0e708ed4354..440d6fb100a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.html @@ -37,19 +37,24 @@
- + @if (!action.visible || action.visible(selection)) { + + }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts index c4fef43e021..efe1d53ea96 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/mirroring/overview/overview.component.ts @@ -53,6 +53,7 @@ export class OverviewComponent implements OnInit, OnDestroy { name: $localize`Create Bootstrap Token`, canBePrimary: () => true, disable: () => false, + visible: () => this.permission.update, buttonKind: 'primary' }; const importBootstrapAction: CdTableAction = { @@ -61,6 +62,7 @@ export class OverviewComponent implements OnInit, OnDestroy { click: () => this.importBootstrapModal(), name: $localize`Import Bootstrap Token`, disable: () => false, + visible: () => this.permission.update, buttonKind: 'tertiary' }; this.tableActions = [createBootstrapAction, importBootstrapAction]; From b2ed52b2a4d86ad6fa19851053badfa9118c4b6f Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Fri, 5 Jun 2026 12:37:38 +0530 Subject: [PATCH 152/596] mgr/dashboard: Fix username validation for special characters by URL-encoding user lookup requests When validating usernames, special characters such as '#' were not being URL-encoded before being appended to the user lookup endpoint. Because the browser treats '#' as a URL fragment, the request path was truncated, causing the backend to return an incorrect response and the UI to display a misleading "User already exists" validation error. Fixes: https://tracker.ceph.com/issues/77129 Signed-off-by: Aashish Sharma --- .../mgr/dashboard/frontend/src/app/shared/api/user.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts index 95c80dd4665..d432e318471 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts @@ -21,7 +21,7 @@ export class UserService { } get(username: string) { - return this.http.get(`api/user/${username}`); + return this.http.get(`api/user/${encodeURIComponent(username)}`); } create(user: UserFormModel) { From 04ec9abec49917e2a882b1252d913c10b9301bb9 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 22 Dec 2025 16:20:56 +0530 Subject: [PATCH 153/596] mgr/cephadm: Setup user's sudoers and public keys before setting cephadm user Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 27 +++++++ src/cephadm/cephadmlib/user_utils.py | 107 +++++++++++++++++++++++++++ src/pybind/mgr/cephadm/module.py | 105 +++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/cephadm/cephadmlib/user_utils.py diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 7cf65220c80..ea292ebb911 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -230,6 +230,8 @@ from cephadmlib.listing_updaters import ( ) from cephadmlib.container_lookup import infer_local_ceph_image, identify from ceph.cephadm.d3n_types import D3NCache, D3NCacheError +from cephadmlib.user_utils import setup_ssh_user + FuncT = TypeVar('FuncT', bound=Callable) @@ -4802,6 +4804,19 @@ def command_cluster_status(ctx: CephadmContext) -> int: return result +def command_setup_ssh_user(ctx: CephadmContext) -> int: + """ + Setup SSH user on the local host by configuring passwordless sudo and + copying SSH public key to user's authorized_keys. + """ + if not ctx.ssh_user: + raise Error('--ssh-user is required') + if not ctx.ssh_pub_key: + raise Error('--ssh-pub-key is required') + + setup_ssh_user(ctx, ctx.ssh_user, ctx.ssh_pub_key) + return 0 + ################################## @@ -5526,6 +5541,18 @@ def _get_parser(): '--expect-hostname', help='Set hostname') + parser_setup_ssh_user = subparsers.add_parser( + 'setup-ssh-user', help='setup SSH user with passwordless sudo and SSH key') + parser_setup_ssh_user.set_defaults(func=command_setup_ssh_user) + parser_setup_ssh_user.add_argument( + '--ssh-user', + required=True, + help='SSH user to setup') + parser_setup_ssh_user.add_argument( + '--ssh-pub-key', + required=True, + help='SSH public key to add to user authorized_keys') + parser_add_repo = subparsers.add_parser( 'add-repo', help='configure package repository') parser_add_repo.set_defaults(func=command_add_repo) diff --git a/src/cephadm/cephadmlib/user_utils.py b/src/cephadm/cephadmlib/user_utils.py new file mode 100644 index 00000000000..39b21d3c44f --- /dev/null +++ b/src/cephadm/cephadmlib/user_utils.py @@ -0,0 +1,107 @@ +# user_utils.py - user management utility functions + +import logging +import os +import pwd +from typing import Tuple + +from .call_wrappers import call, CallVerbosity +from .context import CephadmContext +from .exceptions import Error +from .exe_utils import find_program +from .ssh import authorize_ssh_key + +logger = logging.getLogger() + + +def validate_user_exists(username: str) -> Tuple[int, int, str]: + """Validate that a user exists and return their uid, gid, and home directory. + Args: + username: The username to validate + Returns: + Tuple of (uid, gid, home_directory) + Raises: + Error: If the user does not exist + """ + try: + pwd_entry = pwd.getpwnam(username) + return pwd_entry.pw_uid, pwd_entry.pw_gid, pwd_entry.pw_dir + except KeyError: + raise Error( + f'User {username} does not exist on this host. ' + f'Please create the user first: useradd -m -s /bin/bash {username}' + ) + + +def setup_sudoers(ctx: CephadmContext, username: str) -> None: + """Setup passwordless sudo for a user. + """ + sudoers_file = f'/etc/sudoers.d/{username}' + sudoers_content = f'{username} ALL=(ALL) NOPASSWD: ALL\n' + + logger.info('Setting up sudoers for user %s', username) + try: + # Write sudoers file with proper permissions + with open(sudoers_file, 'w') as f: + f.write(sudoers_content) + os.chmod(sudoers_file, 0o440) + os.chown(sudoers_file, 0, 0) + + # Validate sudoers syntax + visudo_cmd = find_program('visudo') + _out, _err, code = call( + ctx, + [visudo_cmd, '-c', '-f', sudoers_file], + verbosity=CallVerbosity.DEBUG + ) + if code != 0: + # Clean up invalid file + try: + os.remove(sudoers_file) + except OSError: + pass + raise Error(f'Invalid sudoers syntax: {_err}') + logger.info('Successfully configured sudoers for user %s', username) + except Error: + raise + except Exception as e: + msg = f'Failed to setup sudoers for user {username}: {e}' + logger.exception(msg) + raise Error(msg) + + +def setup_ssh_user(ctx: CephadmContext, username: str, ssh_pub_key: str) -> None: + """Setup SSH user with passwordless sudo and SSH key authorization. + This function performs the following operations: + 1. Validates that the user exists on the system + 2. Sets up passwordless sudo for the user (skipped for root) + 3. Authorizes the SSH public key for the user + """ + # Verify we're running as root + if os.geteuid() != 0: + raise Error('This operation must be run as root') + + if not ssh_pub_key or ssh_pub_key.isspace(): + raise Error('SSH public key is required and cannot be empty') + + logger.info('Setting up SSH user %s on this host', username) + + # Validate user exists (will raise Error if not) + validate_user_exists(username) + + # Setup sudoers (skip for root user) + if username != 'root': + setup_sudoers(ctx, username) + else: + logger.debug('Skipping sudoers setup for root user') + + # Setup SSH key using existing function from ssh.py + try: + authorize_ssh_key(ssh_pub_key, username) + logger.info('Successfully authorized SSH key for user %s', username) + except Exception as e: + msg = f'Failed to authorize SSH key for user {username}: {e}' + logger.exception(msg) + raise Error(msg) + + logger.info('Successfully configured SSH user %s on this host', username) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index ef4699838d1..56b072dd51a 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -1181,6 +1181,90 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): return True, err, ret + def _setup_user_on_host(self, host: str, user: str, ssh_pub_key: str, + addr: Optional[str] = None) -> None: + """ + Setup sudoers and copy SSH key by calling cephadm setup-ssh-user command. + User must already exist on the host. + For root user, only SSH key is copied (sudoers setup is skipped). + """ + self.log.debug('Setting up user %s on host %s', user, host) + try: + out, err, code = self.wait_async( + CephadmServe(self)._run_cephadm( + host, + cephadmNoImage, + 'setup-ssh-user', + ['--ssh-user', user, '--ssh-pub-key', ssh_pub_key], + addr=addr, + error_ok=False, + no_fsid=True + ) + ) + if code != 0: + msg = f'Failed to setup user {user} on host {host}: {err}' + self.log.error(msg) + raise OrchestratorError(msg) + self.log.info('Successfully set up user %s on host %s', user, host) + except OrchestratorError: + raise + except Exception as e: + msg = f'Failed to setup user {user} on host {host}: {e}' + self.log.exception(msg) + raise OrchestratorError(msg) + + def _setup_user_on_all_hosts(self, user: str) -> None: + """ + Setup sudoers and copy SSH key on all hosts in the cluster. + For root user, only SSH key is copied (sudoers setup is skipped). + """ + if not self.ssh_pub: + raise OrchestratorError( + 'No SSH public key configured. ' + 'Please generate or set SSH keys first using ' + '`ceph cephadm generate-key` or `ceph cephadm set-pub-key`.' + ) + + hosts = self.cache.get_hosts() + if not hosts: + self.log.warning('No hosts in inventory, skipping user setup') + return + + self.log.info('Setting up user %s on %s host(s)', user, len(hosts)) + + @forall_hosts + def setup_user_on_host(host: str) -> Tuple[str, Optional[str]]: + """Returns (host, error_message) tuple. error_message is None on success.""" + try: + assert self.ssh_pub + self._setup_user_on_host(host, user, self.ssh_pub) + return (host, None) + except Exception as e: + self.log.error('Failed to setup user %s on host %s: %s', user, host, e) + return (host, str(e)) + + results = setup_user_on_host(hosts) + failed_hosts = [(host, error) for host, error in results if error is not None] + if failed_hosts: + self.log.error('Failed to setup user %s on %s of %s host(s)', user, len(failed_hosts), len(hosts)) + error_parts = [f'Failed to setup user {user} on the following hosts:'] + for host, error in failed_hosts: + error_parts.append(f' - {host}: {error}') + error_parts.extend([ + f'\nPlease ensure user {user} exists on these hosts and retry:', + f' 1. Create user: useradd -m -s /bin/bash {user}', + f' 2. Retry: ceph cephadm set-user {user}', + '\nOr manually complete the setup:', + f' 1. Setup sudoers: echo "{user} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/{user}', + f' 2. Set permissions: chmod 440 /etc/sudoers.d/{user}', + ' 3. Copy SSH key: ceph cephadm get-pub-key > ~/ceph.pub', + f' 4. Add key: ssh-copy-id -f -i ~/ceph.pub {user}@HOST', + '\nAnd use --skip-pre-steps flag to skip automatic setup.' + ]) + raise OrchestratorError('\n'.join(error_parts)) + + self.log.info('Successfully set up user %s on all %s host(s)', user, len(hosts)) + def _validate_and_set_ssh_val(self, what: str, new: Optional[str], old: Optional[str]) -> None: self.set_store(what, new) self.ssh._reconfig_ssh() @@ -1344,14 +1428,30 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): @CephadmCLICommand.Read( 'cephadm set-user') - def set_ssh_user(self, user: str) -> Tuple[int, str, str]: + def set_ssh_user(self, user: str, skip_pre_steps: bool = False) -> Tuple[int, str, str]: """ - Set user for SSHing to cluster hosts, passwordless sudo will be needed for non-root users + Set user for SSHing to cluster hosts, passwordless sudo will be needed for non-root users. + + This command will automatically setup passwordless sudo for the user and + copy SSH public key to the user's authorized_keys. + Use --skip-pre-steps if you have already manually configured the user on all hosts. """ current_user = self.ssh_user if user == current_user: return 0, "value unchanged", "" + if user != 'root' and not skip_pre_steps: + self.log.info('Setting up SSH user %s on all cluster hosts', user) + try: + self._setup_user_on_all_hosts(user) + except OrchestratorError as e: + self.log.error('Failed to setup user %s: %s', user, e) + return -errno.EINVAL, '', str(e) + except Exception as e: + msg = f'Failed to setup user {user} on all hosts: {e}' + self.log.exception(msg) + return -errno.EINVAL, '', msg + self._validate_and_set_ssh_val('ssh_user', user, current_user) current_ssh_config = self._get_ssh_config() new_ssh_config = re.sub(r"(\s{2}User\s)(.*)", r"\1" + user, current_ssh_config.stdout) @@ -1930,6 +2030,7 @@ Then run the following: self.update_maintenance_healthcheck() self.event.set() # refresh stray health check self.log.info('Added host %s' % spec.hostname) + return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) @handle_orch_error From 790181e282b899b61560450e3177d39842dfbc0c Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 22 Dec 2025 16:54:36 +0530 Subject: [PATCH 154/596] mgr/cephadm: Handle cephadm set user configuration for add node Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/module.py | 137 +++++++++++++++++++++++-------- src/pybind/mgr/cephadm/ssh.py | 9 +- 2 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 56b072dd51a..457695084b9 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -533,6 +533,10 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.event = Event() self.ssh = ssh.SSHManager(self) + # Track hosts being added - these hosts will use root user temporarily + # even if cluster is configured to use non-root user + self.hosts_being_added: Set[str] = set() + if self.get_store('pause'): self.paused = True else: @@ -1994,44 +1998,111 @@ Then run the following: :param host: host name """ HostSpec.validate(spec) - ip_addr = self._check_valid_addr(spec.hostname, spec.addr) - if spec.addr == spec.hostname and ip_addr: - spec.addr = ip_addr - if spec.hostname in self.inventory and self.inventory.get_addr(spec.hostname) != spec.addr: - self.cache.refresh_all_host_info(spec.hostname) + # Check if this is a new host BEFORE any SSH operations + is_new_host = spec.hostname not in self.inventory + if is_new_host and self.ssh_user and self.ssh_user != 'root': + try: + self.hosts_being_added.add(spec.hostname) + self.log.info(f'Adding new host {spec.hostname}, will use root user temporarily for setup') + except Exception as e: + self.log.warning(f'Failed to add {spec.hostname} to hosts_being_added tracking: {e}') - if spec.oob: - if not spec.oob.get('addr'): - spec.oob['addr'] = self.oob_default_addr - if not spec.oob.get('port'): - spec.oob['port'] = '443' - host_oob_info = dict() - host_oob_info['addr'] = spec.oob['addr'] - host_oob_info['port'] = spec.oob['port'] - host_oob_info['username'] = spec.oob['username'] - host_oob_info['password'] = spec.oob['password'] - self.node_proxy_cache.update_oob(spec.hostname, host_oob_info) + try: + ip_addr = self._check_valid_addr(spec.hostname, spec.addr) + if spec.addr == spec.hostname and ip_addr: + spec.addr = ip_addr + if spec.hostname in self.inventory and self.inventory.get_addr(spec.hostname) != spec.addr: + self.cache.refresh_all_host_info(spec.hostname) - # prime crush map? - if spec.location: - self.check_mon_command({ - 'prefix': 'osd crush add-bucket', - 'name': spec.hostname, - 'type': 'host', - 'args': [f'{k}={v}' for k, v in spec.location.items()], - }) + if spec.oob: + if not spec.oob.get('addr'): + spec.oob['addr'] = self.oob_default_addr + if not spec.oob.get('port'): + spec.oob['port'] = '443' + host_oob_info = dict() + host_oob_info['addr'] = spec.oob['addr'] + host_oob_info['port'] = spec.oob['port'] + host_oob_info['username'] = spec.oob['username'] + host_oob_info['password'] = spec.oob['password'] + self.node_proxy_cache.update_oob(spec.hostname, host_oob_info) - if spec.hostname not in self.inventory: - self.cache.prime_empty_host(spec.hostname) - self.inventory.add_host(spec) - self.offline_hosts_remove(spec.hostname) - if spec.status == 'maintenance': - self.update_maintenance_healthcheck() - self.event.set() # refresh stray health check - self.log.info('Added host %s' % spec.hostname) + # prime crush map? + if spec.location: + self.check_mon_command({ + 'prefix': 'osd crush add-bucket', + 'name': spec.hostname, + 'type': 'host', + 'args': [f'{k}={v}' for k, v in spec.location.items()], + }) - return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) + if spec.hostname not in self.inventory: + self.cache.prime_empty_host(spec.hostname) + self.inventory.add_host(spec) + self.offline_hosts_remove(spec.hostname) + if spec.status == 'maintenance': + self.update_maintenance_healthcheck() + self.event.set() # refresh stray health check + self.log.info('Added host %s' % spec.hostname) + + # If this is a new host and using non-root user, setup the user now + if is_new_host and self.ssh_user and self.ssh_user != 'root': + self.log.info(f'Setting up user {self.ssh_user} on new host {spec.hostname}') + try: + assert self.ssh_pub + self._setup_user_on_host(spec.hostname, self.ssh_user, self.ssh_pub, addr=spec.addr) + self.log.info(f'Successfully set up user {self.ssh_user} on {spec.hostname}') + except OrchestratorError as oe: + # OrchestratorError from user setup (user doesn't exist, SSH failures, etc.) + # Log warning but don't fail the add_host operation + self.log.warning(f'Failed to setup user {self.ssh_user} on {spec.hostname}: {oe}') + self.log.warning( + f'Host {spec.hostname} added but user setup incomplete. ' + f'Please manually setup user {self.ssh_user} on {spec.hostname}') + except Exception as e: + # Unexpected error during user setup + # Log warning but don't fail the add_host operation + self.log.error(f'Unexpected error setting up user {self.ssh_user} on {spec.hostname}: {e}') + self.log.warning(f'You may need to manually setup user {self.ssh_user} on {spec.hostname}') + finally: + # Always remove from hosts_being_added after setup attempt + try: + self.hosts_being_added.discard(spec.hostname) + except Exception as discard_err: + self.log.debug(f'Failed to remove {spec.hostname} from hosts_being_added: {discard_err}') + + # Reset SSH connection so next operations will use configured user + try: + self.ssh.reset_con(spec.hostname) + self.log.debug(f'Reset SSH connection for {spec.hostname} to use configured user') + except Exception as reset_err: + # Connection reset failure is not critical - connection will be recreated on next use + self.log.debug(f'Failed to reset SSH connection for {spec.hostname}: {reset_err}') + elif is_new_host: + # Root user, no need to track + try: + self.hosts_being_added.discard(spec.hostname) + except Exception as e: + self.log.debug(f'Failed to remove {spec.hostname} from hosts_being_added: {e}') + + return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) + except Exception: + # If anything fails in core add_host operations, cleanup the new tracking mechanisms + if is_new_host and self.ssh_user and self.ssh_user != 'root': + # Cleanup tracking set + try: + self.hosts_being_added.discard(spec.hostname) + self.log.debug(f'Cleaned up hosts_being_added tracking for {spec.hostname} after error') + except Exception as cleanup_err: + self.log.debug(f'Failed to cleanup hosts_being_added for {spec.hostname}: {cleanup_err}') + + # Cleanup any stale SSH connection + try: + self.ssh.reset_con(spec.hostname) + self.log.debug(f'Reset SSH connection for {spec.hostname} after error') + except Exception as reset_err: + self.log.debug(f'Failed to reset SSH connection for {spec.hostname}: {reset_err}') + raise @handle_orch_error def add_host(self, spec: HostSpec) -> str: diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index 1ff6db3bf0d..1e3cce55b30 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -272,7 +272,14 @@ class SSHManager: ) -> Tuple[str, str, int]: conn = await self._remote_connection(host, addr) - use_sudo = (self.mgr.ssh_user != 'root') + + # For hosts being added, always use root (no sudo) even if cluster + # is configured to use non-root user. This allows initial setup. + is_host_being_added = host in self.mgr.hosts_being_added + use_sudo = (self.mgr.ssh_user != 'root') and not is_host_being_added + if is_host_being_added: + logger.debug(f'Host {host} is being added, using root user without sudo') + rcmd = RemoteSudoCommand.wrap(cmd_components, use_sudo=use_sudo) try: address = addr or self.mgr.inventory.get_addr(host) From 79924f8fc91ff01aedacbcd5f5e14c89b9c38722 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 2 Dec 2025 10:37:00 +0530 Subject: [PATCH 155/596] cephadm: added cephadm exec command to execute shell commands Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 46 +++++++++++++++++++++++++ src/cephadm/tests/test_cephadm.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index ea292ebb911..d8c99403f51 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -74,6 +74,7 @@ from cephadmlib.context_getters import ( from cephadmlib.exceptions import ( ClusterAlreadyExists, Error, + TimeoutExpired, UnauthorizedRegistryError, DaemonStartException, ) @@ -3565,6 +3566,41 @@ def command_logs(ctx: CephadmContext) -> None: ################################## +def command_exec(ctx: CephadmContext) -> int: + """ + Execute a shell command on the host + Return Codes: + - `0`: Command executed successfully + - `124`: Command timed out + - `1`: Error during execution + - Other: Return code from the executed command + """ + if not ctx.command: + raise Error('No command provided to execute') + cmd = ctx.command + logger.debug('Executing command: %s' % ' '.join(cmd)) + try: + stdout, stderr, returncode = call( + ctx, + cmd, + verbosity=CallVerbosity.SILENT, + timeout=ctx.timeout + ) + if stdout: + sys.stdout.write(stdout) + if stderr: + sys.stderr.write(stderr) + return returncode + except TimeoutExpired: + logger.exception('Command timed out after %s seconds' % ctx.timeout) + return 124 + except Exception as e: + logger.exception('Error executing command: %s' % str(e)) + return 1 + +################################## + + def command_list_networks(ctx): # type: (CephadmContext) -> None r = list_networks(ctx) @@ -5299,6 +5335,16 @@ def _get_parser(): 'command', nargs='*', help='additional journalctl args') + parser_exec = subparsers.add_parser( + 'exec', help='execute a shell command on the host') + parser_exec.set_defaults(func=command_exec) + parser_exec.add_argument( + '--command', nargs=argparse.REMAINDER, + help='command to execute') + parser_exec.add_argument( + '--fsid', + help='cluster FSID') + parser_bootstrap = subparsers.add_parser( 'bootstrap', help='bootstrap a cluster (mon + mgr daemons)') parser_bootstrap.set_defaults(func=command_bootstrap) diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index 7d2402d8b9b..ac2bab1f865 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -3274,3 +3274,60 @@ class TestRmClusterConfigCleanup(fake_filesystem_unittest.TestCase): assert os.path.exists('/etc/ceph/ceph.conf') assert os.path.isdir('/etc/ceph/ceph.conf') + + +class TestExec(object): + """Test cases for the 'cephadm exec' command""" + + @mock.patch.object(_cephadm, 'call') + @mock.patch('cephadm.logger') + def test_exec_non_zero_exit(self, _logger, mock_call): + """Test command execution with non-zero exit code""" + mock_call.return_value = ('', 'command not found\n', 127) + + cmd = ['exec', '--command', 'nonexistent_command'] + with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: + ctx.command = ['nonexistent_command'] + ctx.timeout = 300 + retval = _cephadm.command_exec(ctx) + assert retval == 127 + + @mock.patch('subprocess.run') + @mock.patch('cephadm.logger') + def test_exec_empty_command_list(self, _logger, mock_run): + """Test command_exec with empty command list""" + cmd = ['exec', '--command'] + with with_cephadm_ctx(cmd) as ctx: + ctx.command = [] + ctx.timeout = 300 + with pytest.raises(_cephadm.Error, match='No command provided to execute'): + _cephadm.command_exec(ctx) + + @mock.patch.object(_cephadm, 'call') + @mock.patch('cephadm.logger') + @mock.patch('sys.stdout') + @mock.patch('sys.stderr') + def test_exec_with_stdout_and_stderr(self, mock_stderr, mock_stdout, _logger, mock_call): + """Test command execution with both stdout and stderr""" + mock_call.return_value = ('Standard output\n', 'Standard error\n', 2) + + cmd = ['exec', '--command', 'test_command'] + with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: + ctx.command = ['test_command'] + ctx.timeout = 300 + retval = _cephadm.command_exec(ctx) + assert retval == 2 + mock_stdout.write.assert_called_once_with('Standard output\n') + mock_stderr.write.assert_called_once_with('Standard error\n') + + @mock.patch.object(_cephadm, 'call') + @mock.patch('cephadm.logger') + def test_exec_general_exception(self, _logger, mock_call): + """Test command execution with general exception""" + mock_call.side_effect = Exception('Unexpected error') + cmd = ['exec', '--command', 'test'] + with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: + ctx.command = ['test'] + ctx.timeout = 300 + retval = _cephadm.command_exec(ctx) + assert retval == 1 From a026eb34a26b71f53084c288be388a27c3cb7051 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 23 Dec 2025 15:03:02 +0530 Subject: [PATCH 156/596] mgr/cephadm: Use 'cephadm exec' to execute bash commands, just commands requires to deploy cephadm binary and which command should be executed directly Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/offline_watcher.py | 2 +- src/pybind/mgr/cephadm/serve.py | 40 ++++++++- src/pybind/mgr/cephadm/ssh.py | 89 ++++++++++++++++--- src/pybind/mgr/cephadm/tests/test_cephadm.py | 8 +- .../mgr/cephadm/tests/test_tuned_profiles.py | 44 ++++++--- src/pybind/mgr/cephadm/tuned_profiles.py | 8 +- 6 files changed, 157 insertions(+), 34 deletions(-) diff --git a/src/pybind/mgr/cephadm/offline_watcher.py b/src/pybind/mgr/cephadm/offline_watcher.py index 4aa07e2f584..37c9e3bee58 100644 --- a/src/pybind/mgr/cephadm/offline_watcher.py +++ b/src/pybind/mgr/cephadm/offline_watcher.py @@ -41,7 +41,7 @@ class OfflineHostWatcher(threading.Thread): if host not in self.mgr.offline_hosts: try: rcmd = ssh.RemoteCommand(ssh.Executables.TRUE) - self.mgr.ssh.check_execute_command(host, rcmd, log_command=self.mgr.log_refresh_metadata) + self.mgr.ssh.check_execute_cephadm_exec(host, rcmd, log_command=self.mgr.log_refresh_metadata) except Exception: logger.debug(f'OfflineHostDetector: detected {host} to be offline') # kick serve loop in case corrective action must be taken for offline host diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 1f1b5c2fd6f..54985d2641f 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1401,7 +1401,7 @@ class CephadmServe: continue self.log.info(f'Removing {host}:{path}') cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', path]) - self.mgr.ssh.check_execute_command(host, cmd) + self.mgr.ssh.check_execute_cephadm_exec(host, cmd) updated_files = True self.mgr.cache.removed_client_file(host, path) if updated_files: @@ -1901,7 +1901,43 @@ class CephadmServe: # Use tee (from coreutils) to create a copy of cephadm on the target machine self.log.info(f"Deploying cephadm binary to {host}") await self.mgr.ssh._write_remote_file(host, self.mgr.cephadm_binary_path, - self.mgr._cephadm, addr=addr) + self.mgr._cephadm, addr=addr, bypass_cephadm_exec=True) + async def run_cephadm_exec(self, + host: str, + cmd: List[str], + addr: Optional[str] = None, + stdin: Optional[str] = None, + log_output: Optional[bool] = True, + timeout: Optional[int] = None, + ) -> Tuple[str, str, int]: + """ + Execute a bash command on the remote host via 'cephadm exec --command ' + """ + self.log.debug(f"run_cephadm_exec: Executing command on {host}: {cmd}") + + exec_args = ['--command'] + cmd + try: + out, err, code = await self._run_cephadm( + host=host, + entity=cephadmNoImage, + command='exec', + args=exec_args, + addr=addr, + stdin=stdin, + no_fsid=True, # exec doesn't need fsid + error_ok=True, # We'll handle errors at a higher level + log_output=log_output, + timeout=timeout + ) + stdout = out[0] if out else '' + stderr = err[0] if err else '' + if log_output: + self.log.debug(f"run_cephadm_exec result: code={code}, stdout={stdout}, stderr={stderr}") + return stdout, stderr, code + + except Exception as e: + self.log.exception(f"Error executing command via cephadm exec on {host}: {e}") + return '', str(e), 1 def _retry_failed_operations(self) -> None: self.log.debug('_retry_failed_operations') diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index 1e3cce55b30..ab70b6d902a 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -270,7 +270,6 @@ class SSHManager: addr: Optional[str] = None, log_command: Optional[bool] = True, ) -> Tuple[str, str, int]: - conn = await self._remote_connection(host, addr) # For hosts being added, always use root (no sudo) even if cluster @@ -415,6 +414,68 @@ class SSHManager: with self.mgr.async_timeout_handler(host, " ".join(cmd)): return self.mgr.wait_async(self._check_execute_command(host, cmd, stdin, addr, log_command)) + async def _execute_cephadm_exec(self, + host: str, + cmd_components: RemoteCommand, + stdin: Optional[str] = None, + addr: Optional[str] = None, + log_command: Optional[bool] = True, + ) -> Tuple[str, str, int]: + """ + Execute a command on the remote host via 'cephadm exec --command ' + This routes the command through CephadmServe.run_cephadm_exec + """ + if log_command: + logger.debug(f'Executing command via cephadm exec: {cmd_components}') + + cmd_list = list(cmd_components) + + from cephadm.serve import CephadmServe + + out, err, code = await CephadmServe(self.mgr).run_cephadm_exec( + host=host, + cmd=cmd_list, + addr=addr, + stdin=stdin, + log_output=log_command + ) + return out, err, code + + def execute_cephadm_exec(self, + host: str, + cmd: RemoteCommand, + stdin: Optional[str] = None, + addr: Optional[str] = None, + log_command: Optional[bool] = True + ) -> Tuple[str, str, int]: + with self.mgr.async_timeout_handler(host, " ".join(cmd)): + return self.mgr.wait_async(self._execute_cephadm_exec(host, cmd, stdin, addr, log_command)) + + async def _check_execute_cephadm_exec(self, + host: str, + cmd: RemoteCommand, + stdin: Optional[str] = None, + addr: Optional[str] = None, + log_command: Optional[bool] = True + ) -> str: + """Execute a command via cephadm exec and raise error if it fails""" + out, err, code = await self._execute_cephadm_exec(host, cmd, stdin, addr, log_command) + if code != 0: + msg = f'Command {cmd} failed. {err}' + logger.debug(msg) + raise OrchestratorError(msg) + return out + + def check_execute_cephadm_exec(self, + host: str, + cmd: RemoteCommand, + stdin: Optional[str] = None, + addr: Optional[str] = None, + log_command: Optional[bool] = True, + ) -> str: + with self.mgr.async_timeout_handler(host, " ".join(cmd)): + return self.mgr.wait_async(self._check_execute_cephadm_exec(host, cmd, stdin, addr, log_command)) + async def _write_remote_file(self, host: str, path: str, @@ -423,26 +484,33 @@ class SSHManager: uid: Optional[int] = None, gid: Optional[int] = None, addr: Optional[str] = None, + bypass_cephadm_exec: Optional[bool] = False, ) -> None: + """ + Write a file to a remote host. + """ try: cephadm_tmp_dir = f"/tmp/cephadm-{self.mgr._cluster_fsid}" dirname = os.path.dirname(path) + + # Choose execution method based on bypass flag + execute_method = self._check_execute_command if bypass_cephadm_exec else self._check_execute_cephadm_exec mkdir = RemoteCommand(Executables.MKDIR, ['-p', dirname]) - await self._check_execute_command(host, mkdir, addr=addr) + await execute_method(host, mkdir, addr=addr) mkdir2 = RemoteCommand(Executables.MKDIR, ['-p', cephadm_tmp_dir + dirname]) - await self._check_execute_command(host, mkdir2, addr=addr) + await execute_method(host, mkdir2, addr=addr) tmp_path = cephadm_tmp_dir + path + '.new' touch = RemoteCommand(Executables.TOUCH, [tmp_path]) - await self._check_execute_command(host, touch, addr=addr) + await execute_method(host, touch, addr=addr) if self.mgr.ssh_user != 'root': assert self.mgr.ssh_user chown = RemoteCommand( Executables.CHOWN, ['-R', self.mgr.ssh_user, cephadm_tmp_dir] ) - await self._check_execute_command(host, chown, addr=addr) + await execute_method(host, chown, addr=addr) chmod = RemoteCommand(Executables.CHMOD, [str(644), tmp_path]) - await self._check_execute_command(host, chmod, addr=addr) + await execute_method(host, chmod, addr=addr) with NamedTemporaryFile(prefix='cephadm-write-remote-file-') as f: os.fchmod(f.fileno(), 0o600) f.write(content) @@ -456,11 +524,11 @@ class SSHManager: Executables.CHOWN, ['-R', str(uid) + ':' + str(gid), tmp_path] ) - await self._check_execute_command(host, chown, addr=addr) + await execute_method(host, chown, addr=addr) chmod = RemoteCommand(Executables.CHMOD, [oct(mode)[2:], tmp_path]) - await self._check_execute_command(host, chmod, addr=addr) + await execute_method(host, chmod, addr=addr) mv = RemoteCommand(Executables.MV, ['-Z', tmp_path, path]) - await self._check_execute_command(host, mv, addr=addr) + await execute_method(host, mv, addr=addr) except Exception as e: msg = f"Unable to write {host}:{path}: {e}" logger.exception(msg) @@ -474,10 +542,11 @@ class SSHManager: uid: Optional[int] = None, gid: Optional[int] = None, addr: Optional[str] = None, + bypass_cephadm_exec: Optional[bool] = False, ) -> None: with self.mgr.async_timeout_handler(host, f'writing file {path}'): self.mgr.wait_async(self._write_remote_file( - host, path, content, mode, uid, gid, addr)) + host, path, content, mode, uid, gid, addr, bypass_cephadm_exec)) async def _reset_con(self, host: str) -> None: conn = self.cons.get(host) diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index b97b7f175f2..9bc1148941a 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -2277,9 +2277,9 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_all_client_files() # Make sure both ceph conf locations (default and per fsid) are called _write_file.assert_has_calls([mock.call('test', '/etc/ceph/ceph.conf', b'', - 0o644, 0, 0, None), + 0o644, 0, 0, None, False), mock.call('test', '/var/lib/ceph/fsid/config/ceph.conf', b'', - 0o644, 0, 0, None)] + 0o644, 0, 0, None, False)] ) ceph_conf_files = cephadm_module.cache.get_host_client_files('test') assert len(ceph_conf_files) == 2 @@ -2291,10 +2291,10 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_all_client_files() _write_file.assert_has_calls([mock.call('test', '/etc/ceph/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None), + b'[mon]\nk=v\n', 0o644, 0, 0, None, False), mock.call('test', '/var/lib/ceph/fsid/config/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None)]) + b'[mon]\nk=v\n', 0o644, 0, 0, None, False)]) # reload cephadm_module.cache.last_client_files = {} cephadm_module.cache.load() diff --git a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py index 9db971f6f21..2eba05dfa57 100644 --- a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py @@ -80,6 +80,24 @@ class FakeMgr: {'y': 'y'}).to_json()}) return '' + def async_timeout_handler(self, host='', cmd='', timeout=None): + """Mock async_timeout_handler for tests""" + from contextlib import contextmanager + + @contextmanager + def handler(): + yield + return handler() + + def wait_async(self, coro, timeout=None): + """Mock wait_async for tests - just run the coroutine""" + import asyncio + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + class TestTunedProfiles: tspec1 = TunedProfileSpec('p1', @@ -128,19 +146,19 @@ class TestTunedProfiles: ] _write_profiles.assert_has_calls(calls, any_order=True) - @mock.patch('cephadm.ssh.SSHManager.check_execute_command') - def test_rm_stray_tuned_profiles(self, _check_execute_command): + @mock.patch('cephadm.ssh.SSHManager.check_execute_cephadm_exec') + def test_rm_stray_tuned_profiles(self, _check_execute_cephadm_exec): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test, going to use host "a" and put 4 cephadm generated # profiles "p1" "p2", "p3" and "who" only two of which should be there ("p1", "p2") # as well as a file not generated by cephadm. Only the "p3" and "who" # profiles should be removed from the host. This should total to 4 - # calls to check_execute_command, 1 "ls", 2 "rm", and 1 "sysctl --system" - _check_execute_command.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', - 'p2-cephadm-tuned-profile.conf', - 'p3-cephadm-tuned-profile.conf', - 'who-cephadm-tuned-profile.conf', - 'dont-touch-me']) + # calls to check_execute_cephadm_exec, 1 "ls", 2 "rm", and 1 "sysctl --system" + _check_execute_cephadm_exec.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', + 'p2-cephadm-tuned-profile.conf', + 'p3-cephadm-tuned-profile.conf', + 'who-cephadm-tuned-profile.conf', + 'dont-touch-me']) mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], @@ -169,16 +187,16 @@ class TestTunedProfiles: 'a', RemoteCommand(Executables.SYSCTL, ['--system']) ), ] - _check_execute_command.assert_has_calls(calls, any_order=True) + _check_execute_cephadm_exec.assert_has_calls(calls, any_order=True) - @mock.patch('cephadm.ssh.SSHManager.check_execute_command') + @mock.patch('cephadm.ssh.SSHManager.check_execute_cephadm_exec') @mock.patch('cephadm.ssh.SSHManager.write_remote_file') - def test_write_tuned_profiles(self, _write_remote_file, _check_execute_command): + def test_write_tuned_profiles(self, _write_remote_file, _check_execute_cephadm_exec): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test we will use host "a" and have it so host_needs_tuned_profile_update # returns True for p2 and False for p1 (see FakeCache class). So we should see # 2 ssh calls, one to write p2, one to run sysctl --system - _check_execute_command.return_value = 'success' + _check_execute_cephadm_exec.return_value = 'success' _write_remote_file.return_value = 'success' mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], @@ -186,7 +204,7 @@ class TestTunedProfiles: profiles) tp = TunedProfileUtils(mgr) tp._write_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) - _check_execute_command.assert_called_with( + _check_execute_cephadm_exec.assert_called_with( 'a', RemoteCommand(Executables.SYSCTL, ['--system']) ) _write_remote_file.assert_called_with( diff --git a/src/pybind/mgr/cephadm/tuned_profiles.py b/src/pybind/mgr/cephadm/tuned_profiles.py index 7a37d937904..5dbc2c102d2 100644 --- a/src/pybind/mgr/cephadm/tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tuned_profiles.py @@ -73,7 +73,7 @@ class TunedProfileUtils(): if self.mgr.cache.is_host_unreachable(host): return cmd = ssh.RemoteCommand(ssh.Executables.LS, [SYSCTL_DIR]) - found_files = self.mgr.ssh.check_execute_command(host, cmd, log_command=self.mgr.log_refresh_metadata).split('\n') + found_files = self.mgr.ssh.check_execute_cephadm_exec(host, cmd, log_command=self.mgr.log_refresh_metadata).split('\n') found_files = [s.strip() for s in found_files] profile_names: List[str] = sum([[*p] for p in profiles], []) # extract all profiles names profile_names = list(set(profile_names)) # remove duplicates @@ -85,10 +85,10 @@ class TunedProfileUtils(): if file not in expected_files: logger.info(f'Removing stray tuned profile file {file}') cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', f'{SYSCTL_DIR}/{file}']) - self.mgr.ssh.check_execute_command(host, cmd) + self.mgr.ssh.check_execute_cephadm_exec(host, cmd) updated = True if updated: - self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) + self.mgr.ssh.check_execute_cephadm_exec(host, SYSCTL_SYSTEM_CMD) def _write_tuned_profiles(self, host: str, profiles: List[Dict[str, str]]) -> None: if self.mgr.cache.is_host_unreachable(host): @@ -102,5 +102,5 @@ class TunedProfileUtils(): self.mgr.ssh.write_remote_file(host, profile_filename, content.encode('utf-8')) updated = True if updated: - self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) + self.mgr.ssh.check_execute_cephadm_exec(host, SYSCTL_SYSTEM_CMD) self.mgr.cache.last_tuned_profile_update[host] = datetime_now() From 5bac52b4431d17bcb4ec90fff72c369dbf7cb983 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 2 Dec 2025 13:10:59 +0530 Subject: [PATCH 157/596] cephadm: Added invoker script Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm_invoker.py | 335 ++++++++++++++++++++++++++++++ src/cephadm/tests/test_invoker.py | 120 +++++++++++ src/cephadm/tox.ini | 4 +- 3 files changed, 457 insertions(+), 2 deletions(-) create mode 100755 src/cephadm/cephadm_invoker.py create mode 100644 src/cephadm/tests/test_invoker.py diff --git a/src/cephadm/cephadm_invoker.py b/src/cephadm/cephadm_invoker.py new file mode 100755 index 00000000000..73343fed428 --- /dev/null +++ b/src/cephadm/cephadm_invoker.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +""" +Cephadm Invoker - a wrapper intended for executing cephadm commands with limited sudo priviliges + +This script validates the cephadm binary hash before execution and provides +a secure way to run cephadm commands and deploy binaries. + +Usage: + cephadm_invoker.py run [args...] + cephadm_invoker.py deploy_binary + cephadm_invoker.py check_binary + +Exit Codes: + 0: Success + 1: General error (file not found, permission issues, etc.) + 2: Binary hash mismatch or file doesn't exist (triggers redeployment) + 126: Permission denied during execution +""" + +import argparse +import datetime +import fcntl +import hashlib +import logging +import logging.handlers +import os +import pathlib +import shutil +import sys +from typing import List, Optional, Tuple, IO + + +logger = logging.getLogger('cephadm_invoker') + + +def setup_logging() -> None: + """ + Configure logging to output to both stdout and syslog. + If syslog is unavailable, continues with console logging only. + """ + logger.setLevel(logging.INFO) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + try: + syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') + syslog_handler.setLevel(logging.INFO) + syslog_handler.setFormatter(formatter) + logger.addHandler(syslog_handler) + except (OSError, ImportError): + pass + + +def calculate_hash(content: bytes) -> str: + """ + Calculate SHA256 hash of binary content. + """ + return hashlib.sha256(content).hexdigest() + + +def disable_cloexec(fh: IO[bytes]) -> None: + """ + Disable the CLOEXEC flag on a file descriptor so it remains open across exec(). + """ + fd = fh.fileno() + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags & ~fcntl.FD_CLOEXEC) + + +def extract_hash_from_path(path: str) -> Optional[str]: + """ + Extract the expected hash from cephadm binary path. + Expected path format: /var/lib/ceph/{fsid}/cephadm.{hash} + """ + basename = pathlib.Path(path).name + if basename.startswith('cephadm.') and len(basename) > 8: + return basename[8:] # Extract hash after 'cephadm.' prefix + return None + + +def verify_binary_hash(fh: IO[bytes], expected_hash: str) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Verify that the cephadm binary hash matches the expected hash. + Returns: + Tuple of (is_valid, expected_hash, actual_hash) + """ + try: + content = fh.read() + actual_hash = calculate_hash(content) + + is_valid = actual_hash == expected_hash + return (is_valid, expected_hash, actual_hash) + + except (IOError, OSError) as e: + logger.error('Error reading cephadm binary: %s', e) + return (False, None, None) + + +def execute_cephadm(fd: int, args: List[str]) -> None: + """ + Execute cephadm binary using os.execve with file descriptor (replaces current process). + Uses file descriptor to prevent race conditions between verification and execution. + Exit codes: + 2: Binary not found (triggers redeployment) + 126: Permission denied + 1: OS-specific error code + """ + try: + os.execve(fd, args, os.environ) + except FileNotFoundError: + logger.error('Cephadm binary file descriptor %d not found', fd) + sys.exit(2) + except PermissionError: + logger.error('Permission denied executing cephadm with fd: %d', fd) + sys.exit(126) + except OSError as e: + logger.error( + 'Failed to execute cephadm (fd=%d): errno=%s (%s)', + fd, + e.errno, + e.strerror, + ) + sys.exit(1) + + +def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) -> None: + """ + verify, and execute cephadm binary with hash validation. + """ + fh = None + try: + expected_hash = extract_hash_from_path(binary_path) + if not expected_hash: + logger.error('Could not extract hash from binary path: %s', binary_path) + sys.exit(1) + + fh = open(binary_path, 'rb') + + is_valid, expected_hash, actual_hash = verify_binary_hash(fh, expected_hash) + if is_valid: + # Disable CLOEXEC so the FD stays open across exec + disable_cloexec(fh) + execute_cephadm(fh.fileno(), [binary_path] + cephadm_args) + sys.exit(0) + + if actual_hash is None: + logger.error('Failed to read or hash binary at: %s', binary_path) + sys.exit(2) + else: + # Hash mismatch - backup the corrupted binary + logger.error('Binary hash mismatch at: %s', binary_path) + logger.error('Expected hash (from filename): %s', expected_hash) + logger.error('Actual hash (calculated): %s', actual_hash) + + try: + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + backup_path = f'{binary_path}_hash_mismatch_{timestamp}' + os.rename(binary_path, backup_path) + logger.info('Moved corrupted binary to: %s', backup_path) + except OSError as e: + logger.error('Could not backup corrupted binary: %s', e) + + logger.info('Returning exit code 2 to trigger binary redeployment') + sys.exit(2) + + except (IOError, OSError) as e: + logger.error('Error opening cephadm binary at %s: %s', binary_path, e) + sys.exit(2) + finally: + if fh is not None: + try: + fh.close() + except OSError: + pass + + +def command_run(args: argparse.Namespace) -> int: + """ + Run cephadm binary with arguments after hash verification. + """ + verify_and_execute_cephadm_binary(args.binary, args.args) + return 0 + + +def command_deploy_binary(args: argparse.Namespace) -> int: + """ + Deploy cephadm binary from temporary file to final location. + Performs deployment with proper permissions and directory creation: + 1. Validates temp file exists + 2. Creates destination directory if needed + 3. Sets executable permissions (0o755) + 4. Moves file to final location atomically with locking + """ + temp_file = args.temp_file + final_path = args.final_path + + if not os.path.isfile(temp_file): + logger.error('Temporary file does not exist: %s', temp_file) + return 1 + + final_dir = pathlib.Path(final_path).parent + try: + final_dir.mkdir(parents=True, exist_ok=True) + logger.debug('Created destination directory: %s', final_dir) + except OSError as e: + logger.error('Failed to create directory %s: %s', final_dir, e) + return 1 + + try: + os.chmod(temp_file, 0o755) + logger.debug('Set executable permissions (0o755) on: %s', temp_file) + except OSError as e: + logger.error('Failed to set permissions on %s: %s', temp_file, e) + return 1 + + lock_file = f'{final_path}.lock' + lock_fd = None + try: + # Create lock file and acquire exclusive lock + lock_fd = os.open(lock_file, os.O_CREAT | os.O_RDWR, 0o644) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + + if os.path.exists(final_path): + logger.info('Binary already exists at %s, skipping deployment', final_path) + return 0 + + shutil.move(temp_file, final_path) + logger.info('Successfully deployed cephadm binary to: %s', final_path) + return 0 + + except OSError as e: + logger.error('Failed to deploy %s to %s: %s', temp_file, final_path, e) + return 1 + finally: + if lock_fd is not None: + try: + os.close(lock_fd) + except OSError: + pass + try: + os.unlink(lock_file) + except OSError: + pass + + +def command_check_binary(args: argparse.Namespace) -> int: + """ + Check if a file exists. + Exit codes: + 0: File exists + 2: File does not exist (signals need for deployment) + """ + if pathlib.Path(args.cephadm_binary_path).is_file(): + logger.debug('File exists: %s', args.cephadm_binary_path) + return 0 + else: + logger.debug('File does not exist: %s', args.cephadm_binary_path) + return 2 + + +def create_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Cephadm Invoker - A secure wrapper for executing cephadm commands', + prog='cephadm_invoker.py' + ) + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + run_parser = subparsers.add_parser( + 'run', + help='Run cephadm binary with arguments after hash verification' + ) + run_parser.add_argument( + 'binary', + help='Path to the cephadm binary (must include hash in filename)' + ) + run_parser.add_argument( + 'args', + nargs=argparse.REMAINDER, + help='Arguments to pass to cephadm' + ) + run_parser.set_defaults(func=command_run) + + deploy_parser = subparsers.add_parser( + 'deploy_binary', + help='Deploy cephadm binary from temp file to final location' + ) + deploy_parser.add_argument( + 'temp_file', + help='Path to temporary cephadm binary file' + ) + deploy_parser.add_argument( + 'final_path', + help='Final destination path for cephadm binary' + ) + deploy_parser.set_defaults(func=command_deploy_binary) + + check_parser = subparsers.add_parser( + 'check_binary', + help='Check if a cephadm binary exists (exit 0 if exists, 2 if not)' + ) + check_parser.add_argument( + 'cephadm_binary_path', + help='Path to cephadm binary to check' + ) + check_parser.set_defaults(func=command_check_binary) + + return parser + + +def main() -> int: + """ + Main entry point - parses arguments and dispatches to appropriate handler. + """ + setup_logging() + parser = create_argument_parser() + args = parser.parse_args() + + if not hasattr(args, 'func'): + parser.print_help() + return 1 + try: + return args.func(args) + except SystemExit: + raise + except Exception as e: + logger.error('Error executing command %s: %s', args.command, e) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/cephadm/tests/test_invoker.py b/src/cephadm/tests/test_invoker.py new file mode 100644 index 00000000000..fe5fb5bb945 --- /dev/null +++ b/src/cephadm/tests/test_invoker.py @@ -0,0 +1,120 @@ +# Tests for cephadm_invoker.py - secure wrapper for executing cephadm commands +# +import hashlib +import io +import os +import sys +import tempfile +from pathlib import Path +from unittest import mock +import pytest + +import cephadm_invoker as invoker + + +class TestInvoker: + """Tests for main function.""" + + def test_run_command_valid(self, monkeypatch, tmp_path): + """Test 'run' command with valid binary.""" + content = b'#!/usr/bin/env python3\nprint("test")\n' + hash_value = hashlib.sha256(content).hexdigest() + test_file = tmp_path / f'cephadm.{hash_value}' + test_file.write_bytes(content) + + monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(test_file), 'ls']) + + with mock.patch('os.execve') as mock_execve: + with pytest.raises(SystemExit) as exc_info: + invoker.main() + assert exc_info.value.code == 0 + mock_execve.assert_called_once() + + def test_run_command_hash_mismatch(self, monkeypatch, tmp_path): + """Test 'run' command with hash mismatch.""" + content = b'#!/usr/bin/env python3\nprint("test")\n' + wrong_hash = 'wronghash123' + test_file = tmp_path / f'cephadm.{wrong_hash}' + test_file.write_bytes(content) + + monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(test_file), 'ls']) + + with pytest.raises(SystemExit) as exc_info: + invoker.main() + assert exc_info.value.code == 2 + + def test_run_command_nonexistent(self, monkeypatch, tmp_path): + """Test 'run' command with nonexistent binary.""" + nonexistent = tmp_path / 'nonexistent' + monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(nonexistent), 'ls']) + + with pytest.raises(SystemExit) as exc_info: + invoker.main() + assert exc_info.value.code == 1 + + def test_deploy_command_success(self, monkeypatch, tmp_path): + """Test 'deploy_binary' command.""" + temp_file = tmp_path / 'temp_cephadm' + temp_file.write_text('#!/usr/bin/env python3\nprint("test")') + final_path = tmp_path / 'cephadm' + + monkeypatch.setattr('sys.argv', [ + 'cephadm_invoker.py', + 'deploy_binary', + str(temp_file), + str(final_path) + ]) + + result = invoker.main() + assert result == 0 + assert final_path.exists() + + def test_deploy_command_temp_not_exist(self, monkeypatch, tmp_path): + """Test 'deploy_binary' with nonexistent temp file.""" + temp_file = tmp_path / 'nonexistent' + final_path = tmp_path / 'cephadm' + + monkeypatch.setattr('sys.argv', [ + 'cephadm_invoker.py', + 'deploy_binary', + str(temp_file), + str(final_path) + ]) + + result = invoker.main() + assert result == 1 + + def test_check_existence_exists(self, monkeypatch, tmp_path): + """Test 'check_binary' command when file exists.""" + test_file = tmp_path / 'test_file' + test_file.write_text('content') + + monkeypatch.setattr('sys.argv', [ + 'cephadm_invoker.py', + 'check_binary', + str(test_file) + ]) + + result = invoker.main() + assert result == 0 + + def test_check_existence_not_exists(self, monkeypatch, tmp_path): + """Test 'check_binary' command when file doesn't exist.""" + test_file = tmp_path / 'nonexistent' + + monkeypatch.setattr('sys.argv', [ + 'cephadm_invoker.py', + 'check_binary', + str(test_file) + ]) + + result = invoker.main() + assert result == 2 + + def test_invalid_command(self, monkeypatch): + """Test invalid command.""" + monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'invalid_command']) + with pytest.raises(SystemExit) as exc_info: + invoker.main() + # argparse exits with code 2 for invalid command + assert exc_info.value.code == 2 diff --git a/src/cephadm/tox.ini b/src/cephadm/tox.ini index 751ff8c2558..2ca15427992 100644 --- a/src/cephadm/tox.ini +++ b/src/cephadm/tox.ini @@ -49,7 +49,7 @@ deps = types-PyYAML -rzipapp-reqs.txt -c{toxinidir}/../mypy-constrains.txt -commands = mypy --config-file ../mypy.ini {posargs:cephadm.py cephadmlib} +commands = mypy --config-file ../mypy.ini {posargs:cephadm.py cephadm_invoker.py cephadmlib} [testenv:flake8] allowlist_externals = bash @@ -57,7 +57,7 @@ deps = flake8 flake8-quotes commands = - flake8 --config=tox.ini {posargs:cephadm.py cephadmlib} + flake8 --config=tox.ini {posargs:cephadm.py cephadm_invoker.py cephadmlib} bash -c 'test $(git ls-files 'cephadm.py' 'cephadmlib/*.py' | sort -u | xargs grep "docker.io" | wc -l) == 1' bash -c 'test $(git ls-files 'cephadm.py' 'cephadmlib/*.py' | sort -u | xargs grep "quay.io" | wc -l) == 7' # Downstream distributions may choose to alter this "docker.io" number, From a22ee365985baaf0d16d3f0ce10c8d24621703bb Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Sat, 13 Dec 2025 12:54:25 +0530 Subject: [PATCH 158/596] mgr/cephadm: Cephadm to call invoker if ssh hardening is enabled Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/module.py | 13 ++++ src/pybind/mgr/cephadm/serve.py | 67 ++++++++++++------- src/pybind/mgr/cephadm/ssh.py | 64 +++++++++++++++++- .../cephadm/tests/test_remote_executables.py | 4 +- src/pybind/mgr/cephadm/tests/test_ssh.py | 11 ++- 5 files changed, 125 insertions(+), 34 deletions(-) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 457695084b9..a9df95ff056 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -515,6 +515,14 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): default='169.254.1.1', desc="Default IP address for RedFish API (OOB management)." ), + Option( + 'ssh_hardening', + type='bool', + default=False, + desc='Enable SSH hardening by routing all command execution through invoker.py. ' + 'When enabled, cephadm and bash commands are validated and executed via ' + 'the secure invoker wrapper.' + ), ] for image in DefaultImages: MODULE_OPTIONS.append(Option(image.key, default=image.image_ref, desc=image.desc)) @@ -542,6 +550,9 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): else: self.paused = False + self.ssh_hardening = False + self.invoker_path = '/usr/libexec/cephadm_invoker.py' + # for mypy which does not run the code if TYPE_CHECKING: self.ssh_config_file = None # type: Optional[str] @@ -621,6 +632,8 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.oob_default_addr = '' self.ssh_keepalive_interval = 0 self.ssh_keepalive_count_max = 0 + self.ssh_hardening = False + self.invoker_path = '/usr/libexec/cephadm_invoker.py' self.certificate_duration_days = 0 self.certificate_renewal_threshold_days = 0 self.certificate_automated_rotation_enabled = False diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 54985d2641f..4e60a702802 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -46,7 +46,6 @@ logger = logging.getLogger(__name__) REQUIRES_POST_ACTIONS = ['grafana', 'iscsi', 'prometheus', 'alertmanager', 'rgw', 'nvmeof', 'mgmt-gateway'] -WHICH = ssh.RemoteExecutable('which') CEPHADM_EXE = ssh.RemoteExecutable('/usr/bin/cephadm') @@ -1785,30 +1784,36 @@ class CephadmServe: if stdin and 'agent' not in str(entity): self.log.debug('stdin: %s' % stdin) - cmd = ssh.RemoteCommand(WHICH, ['python3']) - try: - # when connection was broken/closed, retrying resets the connection - python = await self.mgr.ssh._check_execute_command(host, cmd, addr=addr) - except ssh.HostConnectionError: - python = await self.mgr.ssh._check_execute_command(host, cmd, addr=addr) - - # N.B. because the python3 executable is based on the results of the - # which command we can not know it ahead of time and must be converted - # into a RemoteExecutable. - cmd = ssh.RemoteCommand( - ssh.RemoteExecutable(python), - [self.mgr.cephadm_binary_path] + final_args - ) + # If SSH hardening is enabled, call invoker directly without which python + if self.mgr.ssh_hardening and self.mgr.invoker_path: + # For invoker, pass all args as a single string + cmd = ssh.RemoteCommand( + ssh.Executables.INVOKER, + ['run', self.mgr.cephadm_binary_path, '--'] + final_args + ) + else: + # cephadm_binary_path must be converted into a RemoteExecutable. + cmd = ssh.RemoteCommand( + ssh.RemoteExecutable(self.mgr.cephadm_binary_path), + final_args + ) try: out, err, code = await self.mgr.ssh._execute_command( host, cmd, stdin=stdin, addr=addr) - if code == 2: - ls_cmd = ssh.RemoteCommand( - ssh.Executables.LS, - [self.mgr.cephadm_binary_path] - ) - out_ls, err_ls, code_ls = await self.mgr.ssh._execute_command(host, ls_cmd, addr=addr, + if code == 2 or code == 127: + # Use invoker to check file existence when SSH hardening is enabled + if self.mgr.ssh_hardening and self.mgr.invoker_path: + check_cmd = ssh.RemoteCommand( + ssh.Executables.INVOKER, + ['check_existence', self.mgr.cephadm_binary_path] + ) + else: + check_cmd = ssh.RemoteCommand( + ssh.Executables.LS, + [self.mgr.cephadm_binary_path] + ) + out_ls, err_ls, code_ls = await self.mgr.ssh._execute_command(host, check_cmd, addr=addr, log_command=log_output) if code_ls == 2: await self._deploy_cephadm_binary(host, addr) @@ -1828,7 +1833,14 @@ class CephadmServe: elif self.mgr.mode == 'cephadm-package': try: - cmd = ssh.RemoteCommand(CEPHADM_EXE, final_args) + # Wrap with invoker if SSH hardening is enabled + if self.mgr.ssh_hardening and self.mgr.invoker_path: + cmd = ssh.RemoteCommand( + ssh.Executables.INVOKER, + ['run', str(CEPHADM_EXE), '--'] + final_args + ) + else: + cmd = ssh.RemoteCommand(CEPHADM_EXE, final_args) out, err, code = await self.mgr.ssh._execute_command( host, cmd, stdin=stdin, addr=addr) except Exception as e: @@ -1900,8 +1912,15 @@ class CephadmServe: async def _deploy_cephadm_binary(self, host: str, addr: Optional[str] = None) -> None: # Use tee (from coreutils) to create a copy of cephadm on the target machine self.log.info(f"Deploying cephadm binary to {host}") - await self.mgr.ssh._write_remote_file(host, self.mgr.cephadm_binary_path, - self.mgr._cephadm, addr=addr, bypass_cephadm_exec=True) + if self.mgr.ssh_hardening and self.mgr.invoker_path: + # Use invoker for secure deployment when SSH hardening is enabled + await self.mgr.ssh._deploy_cephadm_binary_via_invoker( + host, self.mgr.cephadm_binary_path, self.mgr._cephadm, addr=addr) + else: + await self.mgr.ssh._write_remote_file(host, self.mgr.cephadm_binary_path, + self.mgr._cephadm, addr=addr, mode=0o744, + bypass_cephadm_exec=True) + async def run_cephadm_exec(self, host: str, cmd: List[str], diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index ab70b6d902a..53c0b2209df 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -111,6 +111,7 @@ class Executables(RemoteExecutable, enum.Enum): SYSCTL = RemoteExecutable('sysctl') TOUCH = RemoteExecutable('touch') TRUE = RemoteExecutable('true') + INVOKER = RemoteExecutable('/usr/libexec/cephadm_invoker.py') def __str__(self) -> str: return self.value @@ -263,6 +264,27 @@ class SSHManager: with self.mgr.async_timeout_handler(host, f'ssh {host} (addr {addr})'): return self.mgr.wait_async(self._remote_connection(host, addr)) + def _enforce_ssh_hardening(self, + host: str, + cmd_components: RemoteCommand) -> None: + """ + Enforce that commands are wrapped with invoker when SSH hardening + is enabled. + """ + if not self.mgr.ssh_hardening: + return + + is_wrapped = ( + isinstance(cmd_components.exe, RemoteExecutable) + and str(cmd_components.exe) == str(Executables.INVOKER) + ) + if not is_wrapped: + msg = (f'SSH hardening is enabled but command is not ' + f'wrapped with invoker for host {host}. ' + f'Command: {cmd_components}') + logger.error(msg) + raise OrchestratorError(msg) + async def _execute_command(self, host: str, cmd_components: RemoteCommand, @@ -279,6 +301,9 @@ class SSHManager: if is_host_being_added: logger.debug(f'Host {host} is being added, using root user without sudo') + # Enforce invoker usage if SSH hardening is enabled + self._enforce_ssh_hardening(host, cmd_components) + rcmd = RemoteSudoCommand.wrap(cmd_components, use_sudo=use_sudo) try: address = addr or self.mgr.inventory.get_addr(host) @@ -518,13 +543,14 @@ class SSHManager: conn = await self._remote_connection(host, addr) async with conn.start_sftp_client() as sftp: await sftp.put(f.name, tmp_path) - if uid is not None and gid is not None and mode is not None: + if uid is not None and gid is not None: # shlex quote takes str or byte object, not int chown = RemoteCommand( Executables.CHOWN, ['-R', str(uid) + ':' + str(gid), tmp_path] ) await execute_method(host, chown, addr=addr) + if mode is not None: chmod = RemoteCommand(Executables.CHMOD, [oct(mode)[2:], tmp_path]) await execute_method(host, chmod, addr=addr) mv = RemoteCommand(Executables.MV, ['-Z', tmp_path, path]) @@ -534,6 +560,42 @@ class SSHManager: logger.exception(msg) raise OrchestratorError(msg) + async def _deploy_cephadm_binary_via_invoker( + self, + host: str, + cephadm_path: str, + cephadm_content: bytes, + addr: Optional[str] = None + ) -> None: + """ + Deploy cephadm binary using the invoker for secure operations. + This creates a temp file locally, copies it to remote host, then + calls the invoker to perform all deployment operations. + """ + with NamedTemporaryFile(prefix='cephadm-deploy-', delete=False) as local_tmp: + local_tmp.write(cephadm_content) + local_tmp.flush() + local_tmp_path = local_tmp.name + + try: + remote_tmp_path = f'/tmp/cephadm-{self.mgr._cluster_fsid}.new' + + conn = await self._remote_connection(host, addr) + async with conn.start_sftp_client() as sftp: + await sftp.put(local_tmp_path, remote_tmp_path) + + invoker_cmd = RemoteCommand( + Executables.INVOKER, + ['deploy_cephadm_binary', remote_tmp_path, cephadm_path] + ) + await self._execute_command(host, invoker_cmd, addr=addr) + + finally: + try: + os.unlink(local_tmp_path) + except OSError: + pass + def write_remote_file(self, host: str, path: str, diff --git a/src/pybind/mgr/cephadm/tests/test_remote_executables.py b/src/pybind/mgr/cephadm/tests/test_remote_executables.py index 26b02e9de96..47fa6ee8463 100644 --- a/src/pybind/mgr/cephadm/tests/test_remote_executables.py +++ b/src/pybind/mgr/cephadm/tests/test_remote_executables.py @@ -54,9 +54,9 @@ EXPECTED = [ ('sysctl', True, ssh_py), ('touch', True, ssh_py), ('true', True, ssh_py), - ('which', True, serve_py), # variable executables - ('python', False, serve_py), + ('self.mgr.cephadm_binary_path', False, serve_py), + ('/usr/libexec/cephadm_invoker.py', True, ssh_py), ] diff --git a/src/pybind/mgr/cephadm/tests/test_ssh.py b/src/pybind/mgr/cephadm/tests/test_ssh.py index 95cd87716f2..937e3156760 100644 --- a/src/pybind/mgr/cephadm/tests/test_ssh.py +++ b/src/pybind/mgr/cephadm/tests/test_ssh.py @@ -79,15 +79,12 @@ class TestWithSSH: with with_host(cephadm_module, host): CephadmServe(cephadm_module)._check_host(host) - # Test case 1: command failure - run_test('test1', FakeConn(returncode=1), "Command .+ failed") + # Test case 1: connection error + run_test('test1', FakeConn(exception=asyncssh.ChannelOpenError(1, "", "")), "Unable to reach remote host test1") - # Test case 2: connection error - run_test('test2', FakeConn(exception=asyncssh.ChannelOpenError(1, "", "")), "Unable to reach remote host test2.") - - # Test case 3: asyncssh ProcessError + # Test case 2: asyncssh ProcessError stderr = "my-process-stderr" - run_test('test3', FakeConn(exception=asyncssh.ProcessError(returncode=3, + run_test('test2', FakeConn(exception=asyncssh.ProcessError(returncode=3, env="", command="", subsystem="", From d4f6188d15f7c9622d535002b2770597ed901565 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 26 Dec 2025 17:21:42 +0530 Subject: [PATCH 159/596] mgr/cephadm: Added prepare host for ssh hardning command and add node handling if ssh hardning is enabled It will perform following steps 1. prepare host for ssh hardning (enable passwordless ssh, install cephadm RPM, update sudoers) 2. enable ssh hardning 3. set cephadm user Fixes:https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 84 +++++- src/cephadm/cephadmlib/user_utils.py | 138 +++++++-- src/pybind/mgr/cephadm/module.py | 273 ++++++++++++++---- src/pybind/mgr/cephadm/serve.py | 10 +- src/pybind/mgr/cephadm/ssh.py | 14 +- .../mgr/cephadm/tests/test_ssh_hardening.py | 187 ++++++++++++ 6 files changed, 614 insertions(+), 92 deletions(-) create mode 100644 src/pybind/mgr/cephadm/tests/test_ssh_hardening.py diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index d8c99403f51..34e1f1b5ed2 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -231,7 +231,12 @@ from cephadmlib.listing_updaters import ( ) from cephadmlib.container_lookup import infer_local_ceph_image, identify from ceph.cephadm.d3n_types import D3NCache, D3NCacheError -from cephadmlib.user_utils import setup_ssh_user +from cephadmlib.user_utils import ( + setup_ssh_user, + validate_user_exists, + setup_sudoers_restricted, + install_or_upgrade_cephadm +) FuncT = TypeVar('FuncT', bound=Callable) @@ -4856,6 +4861,65 @@ def command_setup_ssh_user(ctx: CephadmContext) -> int: ################################## +def command_prepare_host_sudo_hardening(ctx: CephadmContext) -> int: + """ + Prepare host for sudo hardening by: + 1. Authorizing SSH public key for the user + 2. Installing/upgrading cephadm package to match cluster version (includes cephadm_invoker.py) + 3. Setting up sudoers with restricted permissions for cephadm_invoker.py + """ + logger.info('Preparing host for sudo hardening...') + + user = ctx.ssh_user if hasattr(ctx, 'ssh_user') and ctx.ssh_user else 'root' + ssh_pub_key = ctx.ssh_pub_key if hasattr(ctx, 'ssh_pub_key') else None + cephadm_version = ctx.cephadm_version if hasattr(ctx, 'cephadm_version') else None + + has_failures = False + try: + validate_user_exists(user) + except Exception as e: + logger.exception('User %s does not exists. err: %s', user, e) + return 1 + # Step 1: Authorize SSH public key for the user + if ssh_pub_key: + try: + logger.debug('Authorizing SSH key for user %s', user) + authorize_ssh_key(ssh_pub_key, user) + except Exception as e: + logger.exception('Failed to authorize SSH key for %s. err: %s', user, e) + has_failures = True + else: + logger.warning('SSH key authorization skipped (no key provided)') + + # Step 2: Install/upgrade the cephadm package (includes cephadm_invoker.py) + success, message = install_or_upgrade_cephadm(ctx, cephadm_version) + if success: + logger.debug('Installed the cephadm package: %s', message) + else: + logger.error('Failed to install the cephadm package: %s', message) + has_failures = True + + # Step 3: Setup sudoers with restricted permissions for cephadm_invoker.py + if user and user != 'root': + try: + setup_sudoers_restricted(ctx, user, '/usr/libexec/cephadm_invoker.py') + logger.debug('Sudoers configured for %s (restricted to cephadm_nvoker.py)', user) + except Exception as e: + logger.exception('Failed to setup sudoers for %s. err: %s', user, e) + has_failures = True + else: + logger.debug('Sudoers setup skipped (root user)') + + logger.info('Successfully prepared host for sudo hardening') + + # Raise error if any step failed + if has_failures: + raise Error('Failed to prepare host for sudo hardening') + return 0 + +################################## + + class ArgumentFacade: def __init__(self) -> None: self.defaults: Dict[str, Any] = {} @@ -5588,7 +5652,7 @@ def _get_parser(): help='Set hostname') parser_setup_ssh_user = subparsers.add_parser( - 'setup-ssh-user', help='setup SSH user with passwordless sudo and SSH key') + 'setup-ssh-user', help='set up SSH user with passwordless sudo and key') parser_setup_ssh_user.set_defaults(func=command_setup_ssh_user) parser_setup_ssh_user.add_argument( '--ssh-user', @@ -5597,7 +5661,21 @@ def _get_parser(): parser_setup_ssh_user.add_argument( '--ssh-pub-key', required=True, - help='SSH public key to add to user authorized_keys') + help='SSH public key to add to authorized_keys') + + parser_prepare_host_sudo_hardening = subparsers.add_parser( + 'prepare-host-sudo-hardening', + help='prepare host by installing cephadm, configuring sudoers, and enabling sudo hardening') + parser_prepare_host_sudo_hardening.set_defaults(func=command_prepare_host_sudo_hardening) + parser_prepare_host_sudo_hardening.add_argument( + '--ssh-user', + help='SSH user to configure (default: root)') + parser_prepare_host_sudo_hardening.add_argument( + '--ssh-pub-key', + help='SSH public key to authorize for the user') + parser_prepare_host_sudo_hardening.add_argument( + '--cephadm-version', + help='Specific cephadm version to install') parser_add_repo = subparsers.add_parser( 'add-repo', help='configure package repository') diff --git a/src/cephadm/cephadmlib/user_utils.py b/src/cephadm/cephadmlib/user_utils.py index 39b21d3c44f..238d55ceca4 100644 --- a/src/cephadm/cephadmlib/user_utils.py +++ b/src/cephadm/cephadmlib/user_utils.py @@ -3,13 +3,15 @@ import logging import os import pwd -from typing import Tuple +from typing import Tuple, Optional from .call_wrappers import call, CallVerbosity from .context import CephadmContext -from .exceptions import Error +from .exceptions import Error, TimeoutExpired from .exe_utils import find_program +from .file_utils import write_new from .ssh import authorize_ssh_key +from .packagers import create_packager logger = logging.getLogger() @@ -33,29 +35,32 @@ def validate_user_exists(username: str) -> Tuple[int, int, str]: ) -def setup_sudoers(ctx: CephadmContext, username: str) -> None: - """Setup passwordless sudo for a user. - """ +def setup_sudoers( + ctx: CephadmContext, username: str, sudoers_content: Optional[str] = None +) -> None: + """Setup sudoers for a user with custom or default permissions.""" sudoers_file = f'/etc/sudoers.d/{username}' - sudoers_content = f'{username} ALL=(ALL) NOPASSWD: ALL\n' + if sudoers_content is None: + sudoers_content = f'{username} ALL=(ALL) NOPASSWD: ALL\n' + + # Ensure content ends with newline + if not sudoers_content.endswith('\n'): + sudoers_content += '\n' logger.info('Setting up sudoers for user %s', username) try: # Write sudoers file with proper permissions - with open(sudoers_file, 'w') as f: - f.write(sudoers_content) - os.chmod(sudoers_file, 0o440) - os.chown(sudoers_file, 0, 0) + with write_new(sudoers_file, owner=(0, 0), perms=0o440) as fh: + fh.write(sudoers_content) # Validate sudoers syntax visudo_cmd = find_program('visudo') _out, _err, code = call( ctx, [visudo_cmd, '-c', '-f', sudoers_file], - verbosity=CallVerbosity.DEBUG + verbosity=CallVerbosity.DEBUG, ) if code != 0: - # Clean up invalid file try: os.remove(sudoers_file) except OSError: @@ -70,17 +75,28 @@ def setup_sudoers(ctx: CephadmContext, username: str) -> None: raise Error(msg) -def setup_ssh_user(ctx: CephadmContext, username: str, ssh_pub_key: str) -> None: +def setup_sudoers_restricted( + ctx: CephadmContext, username: str, allowed_command: str +) -> None: + """Setup sudoers with restricted permissions for a specific command. + Args: + ctx: CephadmContext + username: Username to configure sudoers for + allowed_command: Full path to the command that user can run with sudo + """ + sudoers_content = f'{username} ALL=(ALL) NOPASSWD: {allowed_command}' + setup_sudoers(ctx, username, sudoers_content) + + +def setup_ssh_user( + ctx: CephadmContext, username: str, ssh_pub_key: str +) -> None: """Setup SSH user with passwordless sudo and SSH key authorization. This function performs the following operations: 1. Validates that the user exists on the system 2. Sets up passwordless sudo for the user (skipped for root) 3. Authorizes the SSH public key for the user """ - # Verify we're running as root - if os.geteuid() != 0: - raise Error('This operation must be run as root') - if not ssh_pub_key or ssh_pub_key.isspace(): raise Error('SSH public key is required and cannot be empty') @@ -105,3 +121,91 @@ def setup_ssh_user(ctx: CephadmContext, username: str, ssh_pub_key: str) -> None raise Error(msg) logger.info('Successfully configured SSH user %s on this host', username) + + +def _check_cephadm_version(ctx: CephadmContext) -> Tuple[Optional[str], bool]: + """Check if cephadm is installed and return its version. + Returns: + Tuple of (version_string_or_none, is_available) + """ + try: + result_stdout, result_stderr, result_code = call( + ctx, ['cephadm', 'version'] + ) + if result_code == 0: + version = result_stdout.strip() + logger.info('Found installed cephadm: %s', version) + return version, True + else: + logger.info('cephadm command failed, package not available') + return None, False + except (TimeoutExpired, FileNotFoundError) as e: + logger.exception('cephadm not found: %s', e) + return None, False + + +def install_or_upgrade_cephadm( + ctx: CephadmContext, requested_version: Optional[str] = None +) -> Tuple[bool, str]: + """Install or upgrade cephadm package to match a specific version. + Returns: + Tuple of (success, message) + """ + try: + current_version, is_available = _check_cephadm_version(ctx) + if is_available and current_version: + if requested_version and requested_version not in current_version: + logger.info( + 'Version mismatch: installed=%s, requested=%s', + current_version, + requested_version, + ) + needs_install = True + elif requested_version: + logger.debug( + 'cephadm version %s already installed', requested_version + ) + return True, f'cephadm {requested_version} already installed' + else: + logger.info( + 'cephadm installed, no specific version requested' + ) + return True, f'cephadm already installed: {current_version}' + else: + needs_install = True + + # Install or upgrade if needed + if needs_install: + logger.info('Installing/upgrading cephadm package...') + pkg = create_packager(ctx, version=requested_version) + pkg.install(['cephadm']) + + # Verify installation + new_version, install_success = _check_cephadm_version(ctx) + if install_success and new_version: + logger.info(f'Successfully installed cephadm: {new_version}') + # Check version match if requested + if requested_version and requested_version not in new_version: + logger.warning( + 'Installed version %s does not match requested %s', + new_version, + requested_version, + ) + return ( + True, + f'cephadm installed/upgraded: {new_version} (requested {requested_version} not available)', + ) + return True, f'cephadm installed/upgraded: {new_version}' + else: + logger.warning( + 'cephadm package installed but verification failed' + ) + return ( + True, + 'cephadm package installed (verification unavailable)', + ) + return True, 'cephadm operation completed' + except Exception as e: + error_msg = f'Failed to install/upgrade cephadm: {e}' + logger.exception(error_msg) + return False, error_msg diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index a9df95ff056..cb8c47a2b6d 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -516,10 +516,10 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): desc="Default IP address for RedFish API (OOB management)." ), Option( - 'ssh_hardening', + 'sudo_hardening', type='bool', default=False, - desc='Enable SSH hardening by routing all command execution through invoker.py. ' + desc='Enable sudo hardening by routing all command execution through invoker.py. ' 'When enabled, cephadm and bash commands are validated and executed via ' 'the secure invoker wrapper.' ), @@ -550,7 +550,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): else: self.paused = False - self.ssh_hardening = False + self.sudo_hardening = False self.invoker_path = '/usr/libexec/cephadm_invoker.py' # for mypy which does not run the code @@ -632,7 +632,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.oob_default_addr = '' self.ssh_keepalive_interval = 0 self.ssh_keepalive_count_max = 0 - self.ssh_hardening = False + self.sudo_hardening = False self.invoker_path = '/usr/libexec/cephadm_invoker.py' self.certificate_duration_days = 0 self.certificate_renewal_threshold_days = 0 @@ -1579,6 +1579,126 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.event.set() return 0, '%s (%s) ok' % (host, addr), '\n'.join(err) + def _prepare_host_for_sudo_hardening( + self, + host: str, + cephadm_args: List[str], + addr: Optional[str] = None + ) -> Tuple[str, bool, str]: + """ + Prepare a host for sudo hardening by executing 'cephadm prepare-host-sudo-hardening' command. + """ + try: + self.log.debug('Preparing host %s for sudo hardening...', host) + addr = addr or (self.inventory.get_addr(host) if host in self.inventory else None) + + with self.async_timeout_handler(host, 'cephadm prepare-host-sudo-hardening'): + out, err, code = self.wait_async( + CephadmServe(self)._run_cephadm( + host, cephadmNoImage, 'prepare-host-sudo-hardening', cephadm_args, + addr=addr, error_ok=True, no_fsid=True)) + if code: + error_msg = '\n'.join(err) if err else 'Unknown error' + self.log.error('Failed to prepare host %s: %s', host, error_msg) + return (host, False, error_msg) + self.log.debug('Successfully prepared host %s', host) + return (host, True, '') + except Exception as e: + error_msg = str(e) + self.log.exception('Exception while preparing host %s: %s', host, error_msg) + return (host, False, error_msg) + + @forall_hosts + def _prepare_hosts_for_sudo_hardening( + self, + host: str, + cephadm_args: List[str], + addr: Optional[str] = None + ) -> Tuple[str, bool, str]: + return self._prepare_host_for_sudo_hardening(host, cephadm_args, addr) + + @CephadmCLICommand.Write('cephadm prepare-host-and-enable-sudo-hardening') + def _prepare_host_and_enable_sudo_hardening( + self, + user: str, + host_label: Optional[str] = None + ) -> Tuple[int, str, str]: + """ + Prepare hosts and enable sudo hardening for the cluster. + This command performs a complete sudo hardening setup: + 1. Prepare each host for sudo hardening (install cephadm, configure sudoers) - executed on all hosts + 2. Set SSH user to the specified user for cluster operations (without pre-steps) + 3. Enable sudo hardening globally for the cluster + """ + if not self.ssh_pub: + return 1, '', 'Error: No SSH public key configured. Run "ceph cephadm generate-key" first.' + if host_label: + hosts_to_prepare = [h for h in self.cache.get_hosts() + if self.inventory.has_label(h, host_label)] + else: + hosts_to_prepare = self.cache.get_hosts() + if not hosts_to_prepare: + return 1, '', 'Error: No hosts found' + self.log.debug('Preparing %s host(s) in the cluster: %s', len(hosts_to_prepare), ", ".join(hosts_to_prepare)) + + # Prepare arguments for the cephadm command + args = [] + args.extend(['--ssh-user', user]) + args.extend(['--ssh-pub-key', self.ssh_pub]) + ceph_version = self._get_cephadm_version_for_host_prep() + if ceph_version: + args.extend(['--cephadm-version', ceph_version]) + + # Step 1: Prepare each host for sudo hardening (executed on all target hosts in parallel) + self.log.debug('Step 1: Preparing %s host(s) for sudo hardening in parallel', len(hosts_to_prepare)) + host_args = [(host, args) for host in hosts_to_prepare] + try: + host_results = self._prepare_hosts_for_sudo_hardening(host_args) + except Exception as e: + self.log.exception('Failed to prepare hosts in parallel: %s', e) + return 1, '', f'Failed to prepare hosts: {str(e)}' + failed_hosts = [] + for hostname, success, error_msg in host_results: + if not success: + failed_hosts.append(hostname) + if failed_hosts: + return 1, '', f'Failed to prepare {len(failed_hosts)} host(s): {", ".join(failed_hosts)}' + self.log.debug('All %s hosts prepared successfully', len(hosts_to_prepare)) + + # Step 2: Enable sudo hardening globally + self.log.debug('Step 2: Enabling sudo hardening...') + try: + if not self.sudo_hardening: + self.set_module_option('sudo_hardening', True) + self.sudo_hardening = True + except Exception as e: + error_msg = f'Failed to enable sudo hardening: {e}' + self.log.exception(error_msg) + return 1, '', error_msg + + # Step 3: Set SSH user for cluster operations + self.log.debug('Step 3: Setting SSH user to %s for cluster operations', user) + try: + # Call set_ssh_user with skip_pre_steps=True since we've already prepared the hosts + current_user = self.ssh_user + if current_user != user: + retval, out_msg, err_msg = self.set_ssh_user(user, skip_pre_steps=True) + if retval != 0: + error_msg = f'Failed to set SSH user: {err_msg}' + self.log.error(error_msg) + return 1, '', error_msg + except Exception as e: + error_msg = f'Failed to set SSH user: {e}' + self.log.exception(error_msg) + return 1, '', error_msg + + success_msg = ( + f'Sudo hardening is now active for all cluster operations.\n' + f'Affected hosts: {", ".join(hosts_to_prepare)}\n' + ) + self.log.debug(success_msg) + return 0, success_msg, '' + @CephadmCLICommand.Write( prefix='cephadm set-extra-ceph-conf') def _set_extra_ceph_conf(self, inbuf: Optional[str] = None) -> HandleCommandResult: @@ -2003,6 +2123,74 @@ Then run the following: raise OrchestratorError(str(e)) return ip_addr + def _get_cephadm_version_for_host_prep(self) -> Optional[str]: + """Extract cephadm version from cluster version string.""" + try: + if self.version: + parts = self.version.split() + if len(parts) > 2 and parts[0] == 'ceph' and parts[1] == 'version': + return parts[2] + except Exception as e: + self.log.debug(f'Could not determine cluster version: {e}') + return None + + def _cleanup_add_host_tracking(self, hostname: str) -> None: + """Clean up host from tracking sets and reset SSH connection.""" + try: + self.hosts_being_added.discard(hostname) + except Exception as e: + self.log.debug(f'Failed to remove {hostname} from hosts_being_added: {e}') + + try: + self.ssh.reset_con(hostname) + except Exception as e: + self.log.debug(f'Failed to reset SSH connection for {hostname}: {e}') + + def _prepare_new_host_for_sudo_hardening(self, hostname: str, addr: str) -> None: + """ + Prepare a new host for sudo hardening before adding to inventory. + Raises OrchestratorError on failure. + """ + if not self.ssh_pub: + raise OrchestratorError('No SSH public key configured') + if not self.ssh_user: + raise OrchestratorError('No SSH user configured') + + self.log.info('Preparing new host %s for sudo hardening with root', hostname) + + # Build arguments + ceph_version = self._get_cephadm_version_for_host_prep() + if ceph_version: + self.log.debug('Will install cephadm version %s on %s', ceph_version, hostname) + cephadm_args = ['--ssh-user', self.ssh_user, '--ssh-pub-key', self.ssh_pub] + if ceph_version: + cephadm_args.extend(['--cephadm-version', ceph_version]) + + # Execute preparation + _, success, error_msg = self._prepare_host_for_sudo_hardening( + hostname, cephadm_args, addr + ) + if not success: + raise OrchestratorError( + f'Failed to prepare host {hostname} for sudo hardening: {error_msg}' + ) + self.log.info('Successfully prepared host %s for sudo hardening', hostname) + + def _setup_user_on_new_host(self, hostname: str, addr: Optional[str]) -> None: + """ + user setup for new hosts (when sudo hardening is disabled). + """ + try: + assert self.ssh_pub + assert self.ssh_user + self._setup_user_on_host(hostname, self.ssh_user, self.ssh_pub, addr=addr) + except OrchestratorError as oe: + self.log.exception('Failed to setup user %s on %s: %s', self.ssh_user, hostname, oe) + self.log.warning('Please manually setup user %s on %s', hostname, self.ssh_user, hostname) + except Exception as e: + self.log.exception('Unexpected error setting up user %s on %s: %s', self.ssh_user, hostname, e) + self.log.warning('You may need to manually setup user %s on %s', self.ssh_user, hostname) + def _add_host(self, spec): # type: (HostSpec) -> str """ @@ -2017,9 +2205,9 @@ Then run the following: if is_new_host and self.ssh_user and self.ssh_user != 'root': try: self.hosts_being_added.add(spec.hostname) - self.log.info(f'Adding new host {spec.hostname}, will use root user temporarily for setup') + self.log.info('Adding new host %s, will use root user temporarily for setup', spec.hostname) except Exception as e: - self.log.warning(f'Failed to add {spec.hostname} to hosts_being_added tracking: {e}') + self.log.warning('Failed to add %s to hosts_being_added tracking: %s', spec.hostname, e) try: ip_addr = self._check_valid_addr(spec.hostname, spec.addr) @@ -2049,6 +2237,20 @@ Then run the following: 'args': [f'{k}={v}' for k, v in spec.location.items()], }) + # BEFORE adding host to inventory, prepare it for sudo hardening if needed + if is_new_host and self.sudo_hardening and self.ssh_user and self.ssh_user != 'root': + try: + self._prepare_new_host_for_sudo_hardening(spec.hostname, spec.addr) + except Exception as e: + self.log.exception('Sudo hardening preparation failed for %s: %s', spec.hostname, e) + raise OrchestratorError( + f'Failed to prepare host {spec.hostname} for sudo hardening. ' + f'Host was not added to the cluster. Error: {e}' + ) + elif is_new_host and self.ssh_user and self.ssh_user != 'root': + # Sudo hardening not enabled, just perform set user setup presteps + self._setup_user_on_new_host(spec.hostname, spec.addr) + if spec.hostname not in self.inventory: self.cache.prime_empty_host(spec.hostname) self.inventory.add_host(spec) @@ -2058,64 +2260,13 @@ Then run the following: self.event.set() # refresh stray health check self.log.info('Added host %s' % spec.hostname) - # If this is a new host and using non-root user, setup the user now - if is_new_host and self.ssh_user and self.ssh_user != 'root': - self.log.info(f'Setting up user {self.ssh_user} on new host {spec.hostname}') - try: - assert self.ssh_pub - self._setup_user_on_host(spec.hostname, self.ssh_user, self.ssh_pub, addr=spec.addr) - self.log.info(f'Successfully set up user {self.ssh_user} on {spec.hostname}') - except OrchestratorError as oe: - # OrchestratorError from user setup (user doesn't exist, SSH failures, etc.) - # Log warning but don't fail the add_host operation - self.log.warning(f'Failed to setup user {self.ssh_user} on {spec.hostname}: {oe}') - self.log.warning( - f'Host {spec.hostname} added but user setup incomplete. ' - f'Please manually setup user {self.ssh_user} on {spec.hostname}') - except Exception as e: - # Unexpected error during user setup - # Log warning but don't fail the add_host operation - self.log.error(f'Unexpected error setting up user {self.ssh_user} on {spec.hostname}: {e}') - self.log.warning(f'You may need to manually setup user {self.ssh_user} on {spec.hostname}') - finally: - # Always remove from hosts_being_added after setup attempt - try: - self.hosts_being_added.discard(spec.hostname) - except Exception as discard_err: - self.log.debug(f'Failed to remove {spec.hostname} from hosts_being_added: {discard_err}') - - # Reset SSH connection so next operations will use configured user - try: - self.ssh.reset_con(spec.hostname) - self.log.debug(f'Reset SSH connection for {spec.hostname} to use configured user') - except Exception as reset_err: - # Connection reset failure is not critical - connection will be recreated on next use - self.log.debug(f'Failed to reset SSH connection for {spec.hostname}: {reset_err}') - elif is_new_host: - # Root user, no need to track - try: - self.hosts_being_added.discard(spec.hostname) - except Exception as e: - self.log.debug(f'Failed to remove {spec.hostname} from hosts_being_added: {e}') - return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) except Exception: - # If anything fails in core add_host operations, cleanup the new tracking mechanisms - if is_new_host and self.ssh_user and self.ssh_user != 'root': - # Cleanup tracking set - try: - self.hosts_being_added.discard(spec.hostname) - self.log.debug(f'Cleaned up hosts_being_added tracking for {spec.hostname} after error') - except Exception as cleanup_err: - self.log.debug(f'Failed to cleanup hosts_being_added for {spec.hostname}: {cleanup_err}') - - # Cleanup any stale SSH connection - try: - self.ssh.reset_con(spec.hostname) - self.log.debug(f'Reset SSH connection for {spec.hostname} after error') - except Exception as reset_err: - self.log.debug(f'Failed to reset SSH connection for {spec.hostname}: {reset_err}') raise + finally: + if is_new_host and self.ssh_user and self.ssh_user != 'root': + self.log.debug('Cleaning up %s ', spec.hostname) + self._cleanup_add_host_tracking(spec.hostname) @handle_orch_error def add_host(self, spec: HostSpec) -> str: diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 4e60a702802..0f284942398 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1785,7 +1785,7 @@ class CephadmServe: self.log.debug('stdin: %s' % stdin) # If SSH hardening is enabled, call invoker directly without which python - if self.mgr.ssh_hardening and self.mgr.invoker_path: + if self.mgr.sudo_hardening and self.mgr.invoker_path: # For invoker, pass all args as a single string cmd = ssh.RemoteCommand( ssh.Executables.INVOKER, @@ -1803,10 +1803,10 @@ class CephadmServe: host, cmd, stdin=stdin, addr=addr) if code == 2 or code == 127: # Use invoker to check file existence when SSH hardening is enabled - if self.mgr.ssh_hardening and self.mgr.invoker_path: + if self.mgr.sudo_hardening and self.mgr.invoker_path: check_cmd = ssh.RemoteCommand( ssh.Executables.INVOKER, - ['check_existence', self.mgr.cephadm_binary_path] + ['check_binary', self.mgr.cephadm_binary_path] ) else: check_cmd = ssh.RemoteCommand( @@ -1834,7 +1834,7 @@ class CephadmServe: elif self.mgr.mode == 'cephadm-package': try: # Wrap with invoker if SSH hardening is enabled - if self.mgr.ssh_hardening and self.mgr.invoker_path: + if self.mgr.sudo_hardening and self.mgr.invoker_path: cmd = ssh.RemoteCommand( ssh.Executables.INVOKER, ['run', str(CEPHADM_EXE), '--'] + final_args @@ -1912,7 +1912,7 @@ class CephadmServe: async def _deploy_cephadm_binary(self, host: str, addr: Optional[str] = None) -> None: # Use tee (from coreutils) to create a copy of cephadm on the target machine self.log.info(f"Deploying cephadm binary to {host}") - if self.mgr.ssh_hardening and self.mgr.invoker_path: + if self.mgr.sudo_hardening and self.mgr.invoker_path: # Use invoker for secure deployment when SSH hardening is enabled await self.mgr.ssh._deploy_cephadm_binary_via_invoker( host, self.mgr.cephadm_binary_path, self.mgr._cephadm, addr=addr) diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index 53c0b2209df..f1e842d811e 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -264,14 +264,16 @@ class SSHManager: with self.mgr.async_timeout_handler(host, f'ssh {host} (addr {addr})'): return self.mgr.wait_async(self._remote_connection(host, addr)) - def _enforce_ssh_hardening(self, - host: str, - cmd_components: RemoteCommand) -> None: + def _enforce_sudo_hardening( + self, + host: str, + cmd_components: RemoteCommand + ) -> None: """ Enforce that commands are wrapped with invoker when SSH hardening is enabled. """ - if not self.mgr.ssh_hardening: + if not self.mgr.sudo_hardening: return is_wrapped = ( @@ -302,7 +304,7 @@ class SSHManager: logger.debug(f'Host {host} is being added, using root user without sudo') # Enforce invoker usage if SSH hardening is enabled - self._enforce_ssh_hardening(host, cmd_components) + self._enforce_sudo_hardening(host, cmd_components) rcmd = RemoteSudoCommand.wrap(cmd_components, use_sudo=use_sudo) try: @@ -586,7 +588,7 @@ class SSHManager: invoker_cmd = RemoteCommand( Executables.INVOKER, - ['deploy_cephadm_binary', remote_tmp_path, cephadm_path] + ['deploy_binary', remote_tmp_path, cephadm_path] ) await self._execute_command(host, invoker_cmd, addr=addr) diff --git a/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py b/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py new file mode 100644 index 00000000000..07bfd4befb4 --- /dev/null +++ b/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Tests for SSH hardening functionality in cephadm.""" + +import pytest +from unittest import mock +try: + from unittest.mock import AsyncMock +except ImportError: + from asyncmock import AsyncMock + +from orchestrator import OrchestratorError +from cephadm.ssh import SSHManager, RemoteCommand, Executables +from cephadm.tests.fixtures import with_host + + +class TestSudoHardening: + """Test SSH hardening functionality.""" + + @pytest.fixture + def setup_sudo_hardening(self, cephadm_module): + """Common setup for SSH hardening tests.""" + cephadm_module.sudo_hardening = True + cephadm_module.ssh_user = 'cephadm' + cephadm_module.cephadm_binary_path = '/var/lib/ceph/fsid/cephadm.abc123' + cephadm_module.invoker_path = '/usr/libexec/cephadm_invoker.py' + return cephadm_module + + @pytest.fixture + def mock_connection(self): + """Create a mock SSH connection.""" + mock_conn = AsyncMock() + mock_conn.run.return_value = mock.Mock( + stdout='', stderr='', returncode=0 + ) + return mock_conn + + @pytest.mark.parametrize('command', [ + lambda: RemoteCommand(RemoteCommand('python3'), + ['/var/lib/ceph/fsid/cephadm.abc123', + 'check-host', '--expect-hostname', 'test-host']), + lambda: RemoteCommand(Executables.LS, ['-la', '/tmp']), + lambda: RemoteCommand(RemoteCommand('/var/lib/ceph/fsid/cephadm.abc123'), + ['version']), + ]) + def test_unwrapped_command_errors(self, setup_sudo_hardening, + mock_connection, command): + """Test that unwrapped commands error out when SSH hardening + is enabled.""" + ssh_manager = SSHManager(setup_sudo_hardening) + + with mock.patch.object(ssh_manager, '_remote_connection', + return_value=mock_connection): + cmd = command() + with pytest.raises(OrchestratorError, + match='command is not wrapped with invoker'): + setup_sudo_hardening.wait_async( + ssh_manager._execute_command('test-host', cmd) + ) + + def test_sudo_hardening_disabled_no_wrapping( + self, + cephadm_module, + mock_connection + ): + """Test that commands are not wrapped when SSH hardening + is disabled.""" + cephadm_module.sudo_hardening = False + cephadm_module.ssh_user = 'cephadm' + cephadm_module.cephadm_binary_path = '/var/lib/ceph/fsid/cephadm.abc123' + ssh_manager = SSHManager(cephadm_module) + + with mock.patch.object(ssh_manager, '_remote_connection', + return_value=mock_connection): + cmd = RemoteCommand(Executables.LS, ['-la', '/tmp']) + cephadm_module.wait_async( + ssh_manager._execute_command('test-host', cmd) + ) + + mock_connection.run.assert_called_once() + called_args = mock_connection.run.call_args[0][0] + assert called_args == 'sudo ls -la /tmp' + + def test_wrapped_command_succeeds(self, setup_sudo_hardening, + mock_connection): + """Test that wrapped commands succeed when SSH hardening + is enabled.""" + ssh_manager = SSHManager(setup_sudo_hardening) + + with mock.patch.object(ssh_manager, '_remote_connection', + return_value=mock_connection): + cmd = RemoteCommand( + Executables.INVOKER, + ['run', '/var/lib/ceph/fsid/cephadm.abc123', '--help'] + ) + setup_sudo_hardening.wait_async( + ssh_manager._execute_command('test-host', cmd) + ) + + mock_connection.run.assert_called() + called_args = mock_connection.run.call_args[0][0] + assert called_args == ('sudo /usr/libexec/cephadm_invoker.py ' + 'run /var/lib/ceph/fsid/cephadm.abc123 --help') + + def test_check_host_with_sudo_hardening_integration( + self, setup_sudo_hardening, mock_connection): + """Integration test for check_host with SSH hardening enabled.""" + with mock.patch.object(setup_sudo_hardening, + '_prepare_new_host_for_sudo_hardening'): + with mock.patch.object(setup_sudo_hardening.ssh, '_remote_connection', + return_value=mock_connection): + with with_host(setup_sudo_hardening, 'test-host'): + code, _, err = setup_sudo_hardening.check_host('test-host') + assert code == 0 + assert err == '' + + called_args = mock_connection.run.call_args[0][0] + assert '/usr/libexec/cephadm_invoker.py' in called_args + assert 'sudo' in called_args + assert 'run' in called_args + assert 'check-host' in called_args + assert '--expect-hostname test-host' in called_args + + def test_check_host_without_sudo_hardening( + self, + cephadm_module, + mock_connection + ): + """Integration test for check_host with SSH hardening disabled.""" + cephadm_module.sudo_hardening = False + cephadm_module.ssh_user = 'root' + cephadm_module.cephadm_binary_path = '/var/lib/ceph/fsid/cephadm.abc123' + + with mock.patch.object(cephadm_module.ssh, '_remote_connection', + return_value=mock_connection): + with with_host(cephadm_module, 'test-host'): + code, _, err = cephadm_module.check_host('test-host') + assert code == 0 + assert err == '' + + called_args = mock_connection.run.call_args[0][0] + assert '/usr/libexec/cephadm_invoker.py' not in called_args + assert 'sudo' not in called_args + assert 'check-host' in called_args + assert '--expect-hostname test-host' in called_args + + def test_execute_cephadm_exec_with_hardening(self, setup_sudo_hardening): + """Test cephadm exec with SSH hardening enabled.""" + ssh_manager = SSHManager(setup_sudo_hardening) + mock_conn = AsyncMock() + mock_conn.run.return_value = mock.Mock( + stdout='success', stderr='', returncode=0 + ) + + with mock.patch.object(setup_sudo_hardening.ssh, '_remote_connection', + return_value=mock_conn): + cmd = RemoteCommand(Executables.LS, ['-la', '/tmp']) + out, err, code = ssh_manager.execute_cephadm_exec('test-host', cmd) + + called_args = mock_conn.run.call_args[0][0] + assert called_args.startswith('sudo /usr/libexec/cephadm_invoker.py') + assert setup_sudo_hardening.cephadm_binary_path in called_args + assert 'exec' in called_args and '--command' in called_args + assert (out, err, code) == ('success', '', 0) + + def test_execute_cephadm_exec_without_hardening(self, cephadm_module): + """Test cephadm exec with SSH hardening disabled.""" + cephadm_module.sudo_hardening = False + cephadm_module.ssh_user = 'cephadm' + cephadm_module.cephadm_binary_path = '/var/lib/ceph/fsid/cephadm.abc123' + ssh_manager = SSHManager(cephadm_module) + + mock_conn = AsyncMock() + mock_conn.run.return_value = mock.Mock( + stdout='success', stderr='', returncode=0 + ) + + with mock.patch.object(cephadm_module.ssh, '_remote_connection', + return_value=mock_conn): + cmd = RemoteCommand(Executables.LS, ['-la', '/tmp']) + out, err, code = ssh_manager.execute_cephadm_exec('test-host', cmd) + + called_args = mock_conn.run.call_args[0][0] + # When hardening is disabled, cephadm exec should use cephadm binary directly (not invoker) + assert cephadm_module.cephadm_binary_path in called_args + assert '/usr/libexec/cephadm_invoker.py' not in called_args + assert 'exec' in called_args and '--command' in called_args + assert (out, err, code) == ('success', '', 0) From 3635d7750734421ecc80d5a563e026433fb4bbc1 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 9 Jan 2026 19:43:16 +0530 Subject: [PATCH 160/596] cephadm: Add cephadm_invoker.py in cephadm rpm at /usr/libexec/cephadm_invoker.py Fixes:https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- ceph.spec.in | 1 + src/cephadm/CMakeLists.txt | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/ceph.spec.in b/ceph.spec.in index 0ec85c99755..5d5e2067a74 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -2139,6 +2139,7 @@ exit 0 %files -n cephadm %{_sbindir}/cephadm +%attr(0700,root,root) %{_libexecdir}/cephadm_invoker.py %{_mandir}/man8/cephadm.8* %attr(0700,cephadm,cephadm) %dir %{_sharedstatedir}/cephadm %attr(0700,cephadm,cephadm) %dir %{_sharedstatedir}/cephadm/.ssh diff --git a/src/cephadm/CMakeLists.txt b/src/cephadm/CMakeLists.txt index c8b7c74a985..fcdc886be78 100644 --- a/src/cephadm/CMakeLists.txt +++ b/src/cephadm/CMakeLists.txt @@ -14,6 +14,7 @@ add_custom_command( DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/cephadm.py ${CMAKE_CURRENT_SOURCE_DIR}/build.py + ${CMAKE_CURRENT_SOURCE_DIR}/cephadm_invoker.py WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${Python3_EXECUTABLE} build.py --set-version-var=CEPH_GIT_VER=${CEPH_GIT_VER} @@ -31,3 +32,7 @@ add_custom_target(cephadm ALL install(PROGRAMS ${bin_target_file} DESTINATION ${CMAKE_INSTALL_SBINDIR}) + +install(PROGRAMS + ${CMAKE_CURRENT_SOURCE_DIR}/cephadm_invoker.py + DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}) From 0ecd33ca590252cb3aeffaedc43ff2ee56a15172 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 16 Jan 2026 13:19:06 +0530 Subject: [PATCH 161/596] doc: Documentation for ssh hardening Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/cephadm/host-management.rst | 120 ++++++++++++++++++++++++++-- doc/dev/cephadm/cephadm-invoker.rst | 58 ++++++++++++++ doc/dev/cephadm/index.rst | 1 + doc/man/8/cephadm.rst | 63 ++++++++++++++- 4 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 doc/dev/cephadm/cephadm-invoker.rst diff --git a/doc/cephadm/host-management.rst b/doc/cephadm/host-management.rst index 3f15fb8fce8..7167ae31570 100644 --- a/doc/cephadm/host-management.rst +++ b/doc/cephadm/host-management.rst @@ -551,8 +551,29 @@ cephadm operations. Run a command of the following form: ceph cephadm set-user -Prior to running this, the cluster SSH key needs to be added to this user's -``authorized_keys`` file and non-root users must have passwordless sudo access. +The ``set-user`` command automatically configures the specified user on all cluster +hosts by calling ``cephadm setup-ssh-user`` on each host. This command is available starting +with the Umbrella release and includes the following: + +- Setting up passwordless sudo access for non-root users +- Authorizing the cluster's SSH public key for the user + +If you have already manually configured the user on all hosts, you can skip +the automatic setup by using the ``--skip-pre-steps`` flag: + +.. prompt:: bash # + + ceph cephadm set-user --skip-pre-steps + +For manual setup of SSH users on individual hosts, you can use the +``cephadm setup-ssh-user`` command directly: + +.. prompt:: bash # + + cephadm setup-ssh-user --ssh-user --ssh-pub-key + +This command validates that the user exists, configures passwordless sudo access, +and authorizes the SSH public key. Customizing the SSH Configuration @@ -668,6 +689,95 @@ when executing ``ceph * metadata``. This in turn means cephadm also requires the bare host name when adding a host to the cluster: ``ceph orch host add ``. -.. - TODO: This chapter needs to provide way for users to configure - Grafana in the dashboard, as this is right now very hard to do. +Sudo Hardening +============= + +Cephadm supports sudo hardening to enhance security by restricting sudo privilege +escalation for non-root SSH users. When sudo hardening is enabled, cephadm uses the +``cephadm_invoker.py`` script to securely execute cephadm commands with controlled +privilege escalation. + +Enabling Sudo Hardening +---------------------- + +To enable sudo hardening for the entire cluster, use the following command: + +.. prompt:: bash # + + ceph cephadm prepare-host-and-enable-sudo-hardening + +This command performs a comprehensive sudo hardening setup: + +1. **Host Preparation**: Prepares all cluster hosts for sudo hardening by: + - Installing/upgrading cephadm RPM with the invoker script + - Configuring restricted sudo access for non-root users + - Setting up SSH key authorization + +2. **SSH User Configuration**: Sets the specified user for cluster SSH operations + +3. **Global Enablement**: Enables sudo hardening cluster-wide + +The ```` parameter specifies which non-root user should be configured for SSH access. +This user will have restricted sudo access configured through the sudoers file. + +You can manually prepare a host for sudo hardening using: + +.. prompt:: bash # + + cephadm prepare-host-sudo-hardening --ssh-user --ssh-pub-key + +.. note:: During initial host addition, the root user is used for setup. After + Sudo hardening is enabled, the specified non-root user with restricted sudo + access will be used for ongoing operations. + +Sudo Hardening Workflow +---------------------- + +When sudo hardening is enabled, the following workflow is used: + +1. **Host Addition**: Before adding a new host with ``ceph orch host add``, + the cluster SSH key needs to be added to this user's ``authorized_keys`` + file and non-root users must have passwordless sudo access. +2. **Command Execution**: Instead of executing cephadm directly, commands are + routed through ``cephadm_invoker.py`` +3. **Binary Verification**: The invoker validates the cephadm binary's hash before execution +4. **Secure Execution**: Commands are executed with restricted permissions + +The ``cephadm_invoker.py`` script provides the following subcommands: + +- ``run``: Execute cephadm binary with hash verification +- ``deploy_cephadm_binary``: Deploy cephadm binary to final location +- ``check_existence``: Check if a file exists + +Sudo Access Restrictions +------------------------- + +Sudo hardening restricts sudo access for non-root users to enhance security. When a +host is prepared for sudo hardening, the sudoers configuration is modified to limit +the commands that can be executed with sudo. This prevents unauthorized command +execution while still allowing necessary cephadm operations. + +The sudoers configuration restricts access to only the ``cephadm_invoker.py`` script +and essential system commands, providing a secure execution environment. + +Security Benefits +----------------- +Sudo hardening provides the following security benefits: + +1. The invoker validates the cephadm binary's hash +2. If validation fails, it signals for binary redeployment +3. Commands are executed with restricted sudo permissions +4. All operations are logged for security auditing + + +Disabling Sudo Hardening +----------------------- + +To disable sudo hardening: + +.. prompt:: bash # + + ceph config set mgr mgr/cephadm/sudo_hardening false + +.. note:: Disabling sudo hardening does not automatically revert host configurations. + Hosts that were prepared for sudo hardening will retain the invoker setup. diff --git a/doc/dev/cephadm/cephadm-invoker.rst b/doc/dev/cephadm/cephadm-invoker.rst new file mode 100644 index 00000000000..930b2af85d6 --- /dev/null +++ b/doc/dev/cephadm/cephadm-invoker.rst @@ -0,0 +1,58 @@ +Cephadm Invoker +=============== + +The ``cephadm_invoker.py`` script provides a wrapper intended for executing +cephadm commands with limited sudo priviliges. It is used when sudo hardening +is enabled. + +Overview +-------- + +The cephadm invoker validates the cephadm binary hash before execution and +provides a secure way to run cephadm commands and deploy binaries. It is installed as +part of the cephadm RPM at ``/usr/libexec/cephadm_invoker.py``. + +Commands +-------- + +The invoker supports the following subcommands: + +``run`` + +Execute cephadm binary with arguments after hash verification:: + + cephadm_invoker.py run [args...] + +The binary path must include a hash in the filename for verification. + +``deploy_binary`` + +Deploy cephadm binary from a temporary file to the final location:: + + cephadm_invoker.py deploy_binary + +``check_binary`` + +Check if a cephadm binary exists:: + + cephadm_invoker.py check_binary + +Returns exit code 0 if the file exists, 2 if it does not exist. + +Exit Codes +---------- + +- ``0``: Success +- ``1``: General error (file not found, permission issues, etc.) +- ``2``: Binary hash mismatch or file doesn't exist (triggers redeployment) +- ``126``: Permission denied during execution + +Security Features +----------------- + +The cephadm invoker provides the following security features: + +- **Binary Hash Verification**: Validates the cephadm binary integrity before execution +- **Restricted Execution**: Only allows execution of verified cephadm binaries +- **Secure Deployment**: Safely deploys cephadm binaries with proper permissions +- **Logging**: Comprehensive logging to both console and syslog for audit trails diff --git a/doc/dev/cephadm/index.rst b/doc/dev/cephadm/index.rst index f89e7ed262c..330eeefb059 100644 --- a/doc/dev/cephadm/index.rst +++ b/doc/dev/cephadm/index.rst @@ -11,5 +11,6 @@ CEPHADM Developer Documentation developing-cephadm host-maintenance compliance-check + cephadm-invoker Storage devices and OSDs management <./design/storage_devices_and_osds> scalability-notes diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index ea7ea2651a5..ff2ee2d7695 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,8 +13,8 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,add-repo,rm-repo,install,list-images,update-osd-service} -| ... +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service,exec} +| ... | **cephadm** **pull** @@ -420,6 +420,65 @@ Arguments: * [--expect-hostname EXPECT_HOSTNAME] Set hostname +exec +---- + +Execute a shell command on a cluster host:: + + cephadm exec -- ls -la + +Positional arguments: + +* [command] command to execute + +Arguments: + +* [--timeout TIMEOUT] timeout in seconds (default: None) + + +prepare-host-sudo-hardening +-------------------------- + +Prepare a host for sudo hardening by authorizing SSH keys, installing/upgrading +the cephadm package, and setting up restricted sudoers permissions:: + + cephadm prepare-host-sudo-hardening --ssh-user cephadm --ssh-pub-key + +This command performs the following steps: + +1. Authorizes the provided SSH public key for the specified user +2. Installs or upgrades the cephadm package to match the cluster version (includes cephadm_invoker.py) +3. Sets up sudoers with restricted permissions for cephadm_invoker.py + +Arguments: + +* [--ssh-user SSH_USER] SSH user for key authorization (default: root) +* [--ssh-pub-key SSH_PUB_KEY] SSH public key to authorize +* [--cephadm-version VERSION] Specific cephadm version to install + + +setup-ssh-user +-------------- + +Setup SSH user with passwordless sudo and SSH key authorization:: + + cephadm setup-ssh-user --ssh-user cephadm --ssh-pub-key + +This command configures an SSH user for cephadm operations by: + +1. Validating that the user exists on the system +2. Setting up passwordless sudo for the user (skipped for root) +3. Authorizing the SSH public key for the user + +This command is automatically called by ``ceph cephadm set-user`` to configure +SSH users across all cluster hosts. + +Arguments: + +* [--ssh-user SSH_USER] SSH user to setup (required) +* [--ssh-pub-key SSH_PUB_KEY] SSH public key to add to user's authorized_keys (required) + + pull ---- From 30cb2e354b909d80a4e0d024258b9d155ba84205 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 2 Apr 2026 12:12:47 +0530 Subject: [PATCH 162/596] cephadm: fix bootstrap ordering, configure SSH keys before set-user Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 34e1f1b5ed2..c98805f6c7f 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -2431,8 +2431,7 @@ def prepare_ssh( cli: Callable, wait_for_mgr_restart: Callable ) -> None: - cli(['cephadm', 'set-user', ctx.ssh_user]) - + # SSH identity must be in the mgr before set-user if ctx.ssh_config: logger.info('Using provided ssh config...') mounts = { @@ -2467,6 +2466,8 @@ def prepare_ssh( logger.info('Wrote public SSH key to %s' % ctx.output_pub_ssh_key) authorize_ssh_key(ssh_pub, ctx.ssh_user) + cli(['cephadm', 'set-user', ctx.ssh_user]) + host = get_hostname() logger.info('Adding host %s...' % host) try: From 4995a5924758989c3ea47014aa085c01636af288 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Wed, 15 Apr 2026 18:49:52 +0530 Subject: [PATCH 163/596] Revert "mgr/cephadm: Handle cephadm set user configuration for add node" This reverts commit 46bcde4f812afb6a822ecb782815c49156674ea9. Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/module.py | 140 ++++++++++--------------------- src/pybind/mgr/cephadm/ssh.py | 9 +- 2 files changed, 45 insertions(+), 104 deletions(-) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index cb8c47a2b6d..c81ee9b9c3f 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -541,10 +541,6 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.event = Event() self.ssh = ssh.SSHManager(self) - # Track hosts being added - these hosts will use root user temporarily - # even if cluster is configured to use non-root user - self.hosts_being_added: Set[str] = set() - if self.get_store('pause'): self.paused = True else: @@ -2134,18 +2130,6 @@ Then run the following: self.log.debug(f'Could not determine cluster version: {e}') return None - def _cleanup_add_host_tracking(self, hostname: str) -> None: - """Clean up host from tracking sets and reset SSH connection.""" - try: - self.hosts_being_added.discard(hostname) - except Exception as e: - self.log.debug(f'Failed to remove {hostname} from hosts_being_added: {e}') - - try: - self.ssh.reset_con(hostname) - except Exception as e: - self.log.debug(f'Failed to reset SSH connection for {hostname}: {e}') - def _prepare_new_host_for_sudo_hardening(self, hostname: str, addr: str) -> None: """ Prepare a new host for sudo hardening before adding to inventory. @@ -2176,21 +2160,6 @@ Then run the following: ) self.log.info('Successfully prepared host %s for sudo hardening', hostname) - def _setup_user_on_new_host(self, hostname: str, addr: Optional[str]) -> None: - """ - user setup for new hosts (when sudo hardening is disabled). - """ - try: - assert self.ssh_pub - assert self.ssh_user - self._setup_user_on_host(hostname, self.ssh_user, self.ssh_pub, addr=addr) - except OrchestratorError as oe: - self.log.exception('Failed to setup user %s on %s: %s', self.ssh_user, hostname, oe) - self.log.warning('Please manually setup user %s on %s', hostname, self.ssh_user, hostname) - except Exception as e: - self.log.exception('Unexpected error setting up user %s on %s: %s', self.ssh_user, hostname, e) - self.log.warning('You may need to manually setup user %s on %s', self.ssh_user, hostname) - def _add_host(self, spec): # type: (HostSpec) -> str """ @@ -2199,74 +2168,53 @@ Then run the following: :param host: host name """ HostSpec.validate(spec) + ip_addr = self._check_valid_addr(spec.hostname, spec.addr) + if spec.addr == spec.hostname and ip_addr: + spec.addr = ip_addr - # Check if this is a new host BEFORE any SSH operations - is_new_host = spec.hostname not in self.inventory - if is_new_host and self.ssh_user and self.ssh_user != 'root': + if spec.hostname in self.inventory and self.inventory.get_addr(spec.hostname) != spec.addr: + self.cache.refresh_all_host_info(spec.hostname) + + if spec.oob: + if not spec.oob.get('addr'): + spec.oob['addr'] = self.oob_default_addr + if not spec.oob.get('port'): + spec.oob['port'] = '443' + host_oob_info = dict() + host_oob_info['addr'] = spec.oob['addr'] + host_oob_info['port'] = spec.oob['port'] + host_oob_info['username'] = spec.oob['username'] + host_oob_info['password'] = spec.oob['password'] + self.node_proxy_cache.update_oob(spec.hostname, host_oob_info) + + # prime crush map? + if spec.location: + self.check_mon_command({ + 'prefix': 'osd crush add-bucket', + 'name': spec.hostname, + 'type': 'host', + 'args': [f'{k}={v}' for k, v in spec.location.items()], + }) + + if spec.hostname not in self.inventory: + self.cache.prime_empty_host(spec.hostname) + self.inventory.add_host(spec) + self.offline_hosts_remove(spec.hostname) + if spec.status == 'maintenance': + self.update_maintenance_healthcheck() + self.event.set() # refresh stray health check + self.log.info('Added host %s' % spec.hostname) + + if self.sudo_hardening and self.ssh_user and self.ssh_user != 'root': try: - self.hosts_being_added.add(spec.hostname) - self.log.info('Adding new host %s, will use root user temporarily for setup', spec.hostname) + self._prepare_new_host_for_sudo_hardening(spec.hostname, spec.addr) except Exception as e: - self.log.warning('Failed to add %s to hosts_being_added tracking: %s', spec.hostname, e) - - try: - ip_addr = self._check_valid_addr(spec.hostname, spec.addr) - if spec.addr == spec.hostname and ip_addr: - spec.addr = ip_addr - if spec.hostname in self.inventory and self.inventory.get_addr(spec.hostname) != spec.addr: - self.cache.refresh_all_host_info(spec.hostname) - - if spec.oob: - if not spec.oob.get('addr'): - spec.oob['addr'] = self.oob_default_addr - if not spec.oob.get('port'): - spec.oob['port'] = '443' - host_oob_info = dict() - host_oob_info['addr'] = spec.oob['addr'] - host_oob_info['port'] = spec.oob['port'] - host_oob_info['username'] = spec.oob['username'] - host_oob_info['password'] = spec.oob['password'] - self.node_proxy_cache.update_oob(spec.hostname, host_oob_info) - - # prime crush map? - if spec.location: - self.check_mon_command({ - 'prefix': 'osd crush add-bucket', - 'name': spec.hostname, - 'type': 'host', - 'args': [f'{k}={v}' for k, v in spec.location.items()], - }) - - # BEFORE adding host to inventory, prepare it for sudo hardening if needed - if is_new_host and self.sudo_hardening and self.ssh_user and self.ssh_user != 'root': - try: - self._prepare_new_host_for_sudo_hardening(spec.hostname, spec.addr) - except Exception as e: - self.log.exception('Sudo hardening preparation failed for %s: %s', spec.hostname, e) - raise OrchestratorError( - f'Failed to prepare host {spec.hostname} for sudo hardening. ' - f'Host was not added to the cluster. Error: {e}' - ) - elif is_new_host and self.ssh_user and self.ssh_user != 'root': - # Sudo hardening not enabled, just perform set user setup presteps - self._setup_user_on_new_host(spec.hostname, spec.addr) - - if spec.hostname not in self.inventory: - self.cache.prime_empty_host(spec.hostname) - self.inventory.add_host(spec) - self.offline_hosts_remove(spec.hostname) - if spec.status == 'maintenance': - self.update_maintenance_healthcheck() - self.event.set() # refresh stray health check - self.log.info('Added host %s' % spec.hostname) - - return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) - except Exception: - raise - finally: - if is_new_host and self.ssh_user and self.ssh_user != 'root': - self.log.debug('Cleaning up %s ', spec.hostname) - self._cleanup_add_host_tracking(spec.hostname) + self.log.exception('Sudo hardening preparation failed for %s: %s', spec.hostname, e) + raise OrchestratorError( + f'Failed to prepare host {spec.hostname} for sudo hardening. ' + f'Host was not added to the cluster. Error: {e}' + ) + return "Added host '{}' with addr '{}'".format(spec.hostname, spec.addr) @handle_orch_error def add_host(self, spec: HostSpec) -> str: diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index f1e842d811e..101d488a6ff 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -295,17 +295,10 @@ class SSHManager: log_command: Optional[bool] = True, ) -> Tuple[str, str, int]: conn = await self._remote_connection(host, addr) - - # For hosts being added, always use root (no sudo) even if cluster - # is configured to use non-root user. This allows initial setup. - is_host_being_added = host in self.mgr.hosts_being_added - use_sudo = (self.mgr.ssh_user != 'root') and not is_host_being_added - if is_host_being_added: - logger.debug(f'Host {host} is being added, using root user without sudo') - # Enforce invoker usage if SSH hardening is enabled self._enforce_sudo_hardening(host, cmd_components) + use_sudo = (self.mgr.ssh_user != 'root') rcmd = RemoteSudoCommand.wrap(cmd_components, use_sudo=use_sudo) try: address = addr or self.mgr.inventory.get_addr(host) From 9b9493345aa5d93179a630d1ccd8be9362c600f2 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 20 Apr 2026 10:07:47 +0530 Subject: [PATCH 164/596] mgr/cephadm: Handled cephadm binary creation with different ssh user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since we execute the cephadm binary directly with sudo, we get exit code 1 with a “command not found” error if the binary does not exist. Handled it in run_cephadm to generate cephadm binary in this case. Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/serve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 0f284942398..71ae061cda6 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1801,7 +1801,7 @@ class CephadmServe: try: out, err, code = await self.mgr.ssh._execute_command( host, cmd, stdin=stdin, addr=addr) - if code == 2 or code == 127: + if code == 2 or code == 127 or 'command not found' in err: # Use invoker to check file existence when SSH hardening is enabled if self.mgr.sudo_hardening and self.mgr.invoker_path: check_cmd = ssh.RemoteCommand( From 23cc154e6a949f60694362df445b0af2689ecc25 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Wed, 6 May 2026 11:30:26 +0530 Subject: [PATCH 165/596] cephadm: Addressed review comments for validate_user_exists and command_run of invoker.py Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 6 ++---- src/cephadm/cephadm_invoker.py | 16 +++++++------- src/cephadm/cephadmlib/user_utils.py | 26 +++++++++------------- src/cephadm/tests/test_invoker.py | 32 ++++++++++------------------ 4 files changed, 31 insertions(+), 49 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index c98805f6c7f..74155f19af1 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4876,10 +4876,8 @@ def command_prepare_host_sudo_hardening(ctx: CephadmContext) -> int: cephadm_version = ctx.cephadm_version if hasattr(ctx, 'cephadm_version') else None has_failures = False - try: - validate_user_exists(user) - except Exception as e: - logger.exception('User %s does not exists. err: %s', user, e) + if not validate_user_exists(user): + logger.error('User %s does not exists on this host.', user) return 1 # Step 1: Authorize SSH public key for the user if ssh_pub_key: diff --git a/src/cephadm/cephadm_invoker.py b/src/cephadm/cephadm_invoker.py index 73343fed428..5576655add6 100755 --- a/src/cephadm/cephadm_invoker.py +++ b/src/cephadm/cephadm_invoker.py @@ -127,7 +127,7 @@ def execute_cephadm(fd: int, args: List[str]) -> None: sys.exit(1) -def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) -> None: +def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) -> int: """ verify, and execute cephadm binary with hash validation. """ @@ -136,7 +136,7 @@ def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) expected_hash = extract_hash_from_path(binary_path) if not expected_hash: logger.error('Could not extract hash from binary path: %s', binary_path) - sys.exit(1) + return 1 fh = open(binary_path, 'rb') @@ -145,11 +145,11 @@ def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) # Disable CLOEXEC so the FD stays open across exec disable_cloexec(fh) execute_cephadm(fh.fileno(), [binary_path] + cephadm_args) - sys.exit(0) + return 0 if actual_hash is None: logger.error('Failed to read or hash binary at: %s', binary_path) - sys.exit(2) + return 2 else: # Hash mismatch - backup the corrupted binary logger.error('Binary hash mismatch at: %s', binary_path) @@ -165,11 +165,11 @@ def verify_and_execute_cephadm_binary(binary_path: str, cephadm_args: List[str]) logger.error('Could not backup corrupted binary: %s', e) logger.info('Returning exit code 2 to trigger binary redeployment') - sys.exit(2) + return 2 except (IOError, OSError) as e: logger.error('Error opening cephadm binary at %s: %s', binary_path, e) - sys.exit(2) + return 2 finally: if fh is not None: try: @@ -182,8 +182,8 @@ def command_run(args: argparse.Namespace) -> int: """ Run cephadm binary with arguments after hash verification. """ - verify_and_execute_cephadm_binary(args.binary, args.args) - return 0 + ret = verify_and_execute_cephadm_binary(args.binary, args.args) + return ret def command_deploy_binary(args: argparse.Namespace) -> int: diff --git a/src/cephadm/cephadmlib/user_utils.py b/src/cephadm/cephadmlib/user_utils.py index 238d55ceca4..884c7242da8 100644 --- a/src/cephadm/cephadmlib/user_utils.py +++ b/src/cephadm/cephadmlib/user_utils.py @@ -16,23 +16,13 @@ from .packagers import create_packager logger = logging.getLogger() -def validate_user_exists(username: str) -> Tuple[int, int, str]: - """Validate that a user exists and return their uid, gid, and home directory. - Args: - username: The username to validate - Returns: - Tuple of (uid, gid, home_directory) - Raises: - Error: If the user does not exist - """ +def validate_user_exists(username: str) -> bool: + """Validate that a user exists""" try: - pwd_entry = pwd.getpwnam(username) - return pwd_entry.pw_uid, pwd_entry.pw_gid, pwd_entry.pw_dir + pwd.getpwnam(username) + return True except KeyError: - raise Error( - f'User {username} does not exist on this host. ' - f'Please create the user first: useradd -m -s /bin/bash {username}' - ) + return False def setup_sudoers( @@ -103,7 +93,11 @@ def setup_ssh_user( logger.info('Setting up SSH user %s on this host', username) # Validate user exists (will raise Error if not) - validate_user_exists(username) + if not validate_user_exists(username): + raise Error( + f'User {username} does not exist on this host. ' + f'Please create the user first: useradd -m -s /bin/bash {username}' + ) # Setup sudoers (skip for root user) if username != 'root': diff --git a/src/cephadm/tests/test_invoker.py b/src/cephadm/tests/test_invoker.py index fe5fb5bb945..1e74366a375 100644 --- a/src/cephadm/tests/test_invoker.py +++ b/src/cephadm/tests/test_invoker.py @@ -1,10 +1,6 @@ # Tests for cephadm_invoker.py - secure wrapper for executing cephadm commands # import hashlib -import io -import os -import sys -import tempfile from pathlib import Path from unittest import mock import pytest @@ -25,9 +21,7 @@ class TestInvoker: monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(test_file), 'ls']) with mock.patch('os.execve') as mock_execve: - with pytest.raises(SystemExit) as exc_info: - invoker.main() - assert exc_info.value.code == 0 + assert invoker.main() == 0 mock_execve.assert_called_once() def test_run_command_hash_mismatch(self, monkeypatch, tmp_path): @@ -36,21 +30,17 @@ class TestInvoker: wrong_hash = 'wronghash123' test_file = tmp_path / f'cephadm.{wrong_hash}' test_file.write_bytes(content) - + monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(test_file), 'ls']) - - with pytest.raises(SystemExit) as exc_info: - invoker.main() - assert exc_info.value.code == 2 + + assert invoker.main() == 2 def test_run_command_nonexistent(self, monkeypatch, tmp_path): """Test 'run' command with nonexistent binary.""" nonexistent = tmp_path / 'nonexistent' monkeypatch.setattr('sys.argv', ['cephadm_invoker.py', 'run', str(nonexistent), 'ls']) - - with pytest.raises(SystemExit) as exc_info: - invoker.main() - assert exc_info.value.code == 1 + + assert invoker.main() == 1 def test_deploy_command_success(self, monkeypatch, tmp_path): """Test 'deploy_binary' command.""" @@ -73,14 +63,14 @@ class TestInvoker: """Test 'deploy_binary' with nonexistent temp file.""" temp_file = tmp_path / 'nonexistent' final_path = tmp_path / 'cephadm' - + monkeypatch.setattr('sys.argv', [ 'cephadm_invoker.py', 'deploy_binary', str(temp_file), str(final_path) ]) - + result = invoker.main() assert result == 1 @@ -88,13 +78,13 @@ class TestInvoker: """Test 'check_binary' command when file exists.""" test_file = tmp_path / 'test_file' test_file.write_text('content') - + monkeypatch.setattr('sys.argv', [ - 'cephadm_invoker.py', + 'cephadm_invoker.py', 'check_binary', str(test_file) ]) - + result = invoker.main() assert result == 0 From 03aa50ef236fe4819fba05d56753a3c02b45e844 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 8 May 2026 13:11:44 +0530 Subject: [PATCH 166/596] cephadm: Removed 'cephadm exec' command Fixes: https://tracker.ceph.com/issues/74045 Revert "mgr/cephadm: Use 'cephadm exec' to execute bash commands, just commands requires to deploy cephadm binary and which command should be executed directly" This reverts commit 3a7000aa5010a8a4ad862b321aa32f36a49ac283. Revert "cephadm: added cephadm exec command to execute shell commands" This reverts commit 53d87b0697b5a6bd08caa03d0d795880ba4b742b. Signed-off-by: Shweta Bhosale --- doc/man/8/cephadm.rst | 18 +--- src/cephadm/cephadm.py | 46 ---------- src/cephadm/tests/test_cephadm.py | 57 ------------ src/pybind/mgr/cephadm/offline_watcher.py | 2 +- src/pybind/mgr/cephadm/serve.py | 42 +-------- src/pybind/mgr/cephadm/ssh.py | 89 +++---------------- src/pybind/mgr/cephadm/tests/test_cephadm.py | 8 +- .../mgr/cephadm/tests/test_ssh_hardening.py | 43 --------- .../mgr/cephadm/tests/test_tuned_profiles.py | 44 +++------ src/pybind/mgr/cephadm/tuned_profiles.py | 8 +- 10 files changed, 35 insertions(+), 322 deletions(-) diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index ff2ee2d7695..44d3beafb20 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service,exec} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -420,22 +420,6 @@ Arguments: * [--expect-hostname EXPECT_HOSTNAME] Set hostname -exec ----- - -Execute a shell command on a cluster host:: - - cephadm exec -- ls -la - -Positional arguments: - -* [command] command to execute - -Arguments: - -* [--timeout TIMEOUT] timeout in seconds (default: None) - - prepare-host-sudo-hardening -------------------------- diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 74155f19af1..ba246ab7c40 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -74,7 +74,6 @@ from cephadmlib.context_getters import ( from cephadmlib.exceptions import ( ClusterAlreadyExists, Error, - TimeoutExpired, UnauthorizedRegistryError, DaemonStartException, ) @@ -3572,41 +3571,6 @@ def command_logs(ctx: CephadmContext) -> None: ################################## -def command_exec(ctx: CephadmContext) -> int: - """ - Execute a shell command on the host - Return Codes: - - `0`: Command executed successfully - - `124`: Command timed out - - `1`: Error during execution - - Other: Return code from the executed command - """ - if not ctx.command: - raise Error('No command provided to execute') - cmd = ctx.command - logger.debug('Executing command: %s' % ' '.join(cmd)) - try: - stdout, stderr, returncode = call( - ctx, - cmd, - verbosity=CallVerbosity.SILENT, - timeout=ctx.timeout - ) - if stdout: - sys.stdout.write(stdout) - if stderr: - sys.stderr.write(stderr) - return returncode - except TimeoutExpired: - logger.exception('Command timed out after %s seconds' % ctx.timeout) - return 124 - except Exception as e: - logger.exception('Error executing command: %s' % str(e)) - return 1 - -################################## - - def command_list_networks(ctx): # type: (CephadmContext) -> None r = list_networks(ctx) @@ -5398,16 +5362,6 @@ def _get_parser(): 'command', nargs='*', help='additional journalctl args') - parser_exec = subparsers.add_parser( - 'exec', help='execute a shell command on the host') - parser_exec.set_defaults(func=command_exec) - parser_exec.add_argument( - '--command', nargs=argparse.REMAINDER, - help='command to execute') - parser_exec.add_argument( - '--fsid', - help='cluster FSID') - parser_bootstrap = subparsers.add_parser( 'bootstrap', help='bootstrap a cluster (mon + mgr daemons)') parser_bootstrap.set_defaults(func=command_bootstrap) diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index ac2bab1f865..7d2402d8b9b 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -3274,60 +3274,3 @@ class TestRmClusterConfigCleanup(fake_filesystem_unittest.TestCase): assert os.path.exists('/etc/ceph/ceph.conf') assert os.path.isdir('/etc/ceph/ceph.conf') - - -class TestExec(object): - """Test cases for the 'cephadm exec' command""" - - @mock.patch.object(_cephadm, 'call') - @mock.patch('cephadm.logger') - def test_exec_non_zero_exit(self, _logger, mock_call): - """Test command execution with non-zero exit code""" - mock_call.return_value = ('', 'command not found\n', 127) - - cmd = ['exec', '--command', 'nonexistent_command'] - with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: - ctx.command = ['nonexistent_command'] - ctx.timeout = 300 - retval = _cephadm.command_exec(ctx) - assert retval == 127 - - @mock.patch('subprocess.run') - @mock.patch('cephadm.logger') - def test_exec_empty_command_list(self, _logger, mock_run): - """Test command_exec with empty command list""" - cmd = ['exec', '--command'] - with with_cephadm_ctx(cmd) as ctx: - ctx.command = [] - ctx.timeout = 300 - with pytest.raises(_cephadm.Error, match='No command provided to execute'): - _cephadm.command_exec(ctx) - - @mock.patch.object(_cephadm, 'call') - @mock.patch('cephadm.logger') - @mock.patch('sys.stdout') - @mock.patch('sys.stderr') - def test_exec_with_stdout_and_stderr(self, mock_stderr, mock_stdout, _logger, mock_call): - """Test command execution with both stdout and stderr""" - mock_call.return_value = ('Standard output\n', 'Standard error\n', 2) - - cmd = ['exec', '--command', 'test_command'] - with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: - ctx.command = ['test_command'] - ctx.timeout = 300 - retval = _cephadm.command_exec(ctx) - assert retval == 2 - mock_stdout.write.assert_called_once_with('Standard output\n') - mock_stderr.write.assert_called_once_with('Standard error\n') - - @mock.patch.object(_cephadm, 'call') - @mock.patch('cephadm.logger') - def test_exec_general_exception(self, _logger, mock_call): - """Test command execution with general exception""" - mock_call.side_effect = Exception('Unexpected error') - cmd = ['exec', '--command', 'test'] - with with_cephadm_ctx(cmd, mock_cephadm_call_fn=False) as ctx: - ctx.command = ['test'] - ctx.timeout = 300 - retval = _cephadm.command_exec(ctx) - assert retval == 1 diff --git a/src/pybind/mgr/cephadm/offline_watcher.py b/src/pybind/mgr/cephadm/offline_watcher.py index 37c9e3bee58..4aa07e2f584 100644 --- a/src/pybind/mgr/cephadm/offline_watcher.py +++ b/src/pybind/mgr/cephadm/offline_watcher.py @@ -41,7 +41,7 @@ class OfflineHostWatcher(threading.Thread): if host not in self.mgr.offline_hosts: try: rcmd = ssh.RemoteCommand(ssh.Executables.TRUE) - self.mgr.ssh.check_execute_cephadm_exec(host, rcmd, log_command=self.mgr.log_refresh_metadata) + self.mgr.ssh.check_execute_command(host, rcmd, log_command=self.mgr.log_refresh_metadata) except Exception: logger.debug(f'OfflineHostDetector: detected {host} to be offline') # kick serve loop in case corrective action must be taken for offline host diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 71ae061cda6..e45ec846df2 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1400,7 +1400,7 @@ class CephadmServe: continue self.log.info(f'Removing {host}:{path}') cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', path]) - self.mgr.ssh.check_execute_cephadm_exec(host, cmd) + self.mgr.ssh.check_execute_command(host, cmd) updated_files = True self.mgr.cache.removed_client_file(host, path) if updated_files: @@ -1918,45 +1918,7 @@ class CephadmServe: host, self.mgr.cephadm_binary_path, self.mgr._cephadm, addr=addr) else: await self.mgr.ssh._write_remote_file(host, self.mgr.cephadm_binary_path, - self.mgr._cephadm, addr=addr, mode=0o744, - bypass_cephadm_exec=True) - - async def run_cephadm_exec(self, - host: str, - cmd: List[str], - addr: Optional[str] = None, - stdin: Optional[str] = None, - log_output: Optional[bool] = True, - timeout: Optional[int] = None, - ) -> Tuple[str, str, int]: - """ - Execute a bash command on the remote host via 'cephadm exec --command ' - """ - self.log.debug(f"run_cephadm_exec: Executing command on {host}: {cmd}") - - exec_args = ['--command'] + cmd - try: - out, err, code = await self._run_cephadm( - host=host, - entity=cephadmNoImage, - command='exec', - args=exec_args, - addr=addr, - stdin=stdin, - no_fsid=True, # exec doesn't need fsid - error_ok=True, # We'll handle errors at a higher level - log_output=log_output, - timeout=timeout - ) - stdout = out[0] if out else '' - stderr = err[0] if err else '' - if log_output: - self.log.debug(f"run_cephadm_exec result: code={code}, stdout={stdout}, stderr={stderr}") - return stdout, stderr, code - - except Exception as e: - self.log.exception(f"Error executing command via cephadm exec on {host}: {e}") - return '', str(e), 1 + self.mgr._cephadm, addr=addr, mode=0o744) def _retry_failed_operations(self) -> None: self.log.debug('_retry_failed_operations') diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index 101d488a6ff..4e8cfcdb5e5 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -294,6 +294,7 @@ class SSHManager: addr: Optional[str] = None, log_command: Optional[bool] = True, ) -> Tuple[str, str, int]: + conn = await self._remote_connection(host, addr) # Enforce invoker usage if SSH hardening is enabled self._enforce_sudo_hardening(host, cmd_components) @@ -434,68 +435,6 @@ class SSHManager: with self.mgr.async_timeout_handler(host, " ".join(cmd)): return self.mgr.wait_async(self._check_execute_command(host, cmd, stdin, addr, log_command)) - async def _execute_cephadm_exec(self, - host: str, - cmd_components: RemoteCommand, - stdin: Optional[str] = None, - addr: Optional[str] = None, - log_command: Optional[bool] = True, - ) -> Tuple[str, str, int]: - """ - Execute a command on the remote host via 'cephadm exec --command ' - This routes the command through CephadmServe.run_cephadm_exec - """ - if log_command: - logger.debug(f'Executing command via cephadm exec: {cmd_components}') - - cmd_list = list(cmd_components) - - from cephadm.serve import CephadmServe - - out, err, code = await CephadmServe(self.mgr).run_cephadm_exec( - host=host, - cmd=cmd_list, - addr=addr, - stdin=stdin, - log_output=log_command - ) - return out, err, code - - def execute_cephadm_exec(self, - host: str, - cmd: RemoteCommand, - stdin: Optional[str] = None, - addr: Optional[str] = None, - log_command: Optional[bool] = True - ) -> Tuple[str, str, int]: - with self.mgr.async_timeout_handler(host, " ".join(cmd)): - return self.mgr.wait_async(self._execute_cephadm_exec(host, cmd, stdin, addr, log_command)) - - async def _check_execute_cephadm_exec(self, - host: str, - cmd: RemoteCommand, - stdin: Optional[str] = None, - addr: Optional[str] = None, - log_command: Optional[bool] = True - ) -> str: - """Execute a command via cephadm exec and raise error if it fails""" - out, err, code = await self._execute_cephadm_exec(host, cmd, stdin, addr, log_command) - if code != 0: - msg = f'Command {cmd} failed. {err}' - logger.debug(msg) - raise OrchestratorError(msg) - return out - - def check_execute_cephadm_exec(self, - host: str, - cmd: RemoteCommand, - stdin: Optional[str] = None, - addr: Optional[str] = None, - log_command: Optional[bool] = True, - ) -> str: - with self.mgr.async_timeout_handler(host, " ".join(cmd)): - return self.mgr.wait_async(self._check_execute_cephadm_exec(host, cmd, stdin, addr, log_command)) - async def _write_remote_file(self, host: str, path: str, @@ -504,33 +443,26 @@ class SSHManager: uid: Optional[int] = None, gid: Optional[int] = None, addr: Optional[str] = None, - bypass_cephadm_exec: Optional[bool] = False, ) -> None: - """ - Write a file to a remote host. - """ try: cephadm_tmp_dir = f"/tmp/cephadm-{self.mgr._cluster_fsid}" dirname = os.path.dirname(path) - - # Choose execution method based on bypass flag - execute_method = self._check_execute_command if bypass_cephadm_exec else self._check_execute_cephadm_exec mkdir = RemoteCommand(Executables.MKDIR, ['-p', dirname]) - await execute_method(host, mkdir, addr=addr) + await self._check_execute_command(host, mkdir, addr=addr) mkdir2 = RemoteCommand(Executables.MKDIR, ['-p', cephadm_tmp_dir + dirname]) - await execute_method(host, mkdir2, addr=addr) + await self._check_execute_command(host, mkdir2, addr=addr) tmp_path = cephadm_tmp_dir + path + '.new' touch = RemoteCommand(Executables.TOUCH, [tmp_path]) - await execute_method(host, touch, addr=addr) + await self._check_execute_command(host, touch, addr=addr) if self.mgr.ssh_user != 'root': assert self.mgr.ssh_user chown = RemoteCommand( Executables.CHOWN, ['-R', self.mgr.ssh_user, cephadm_tmp_dir] ) - await execute_method(host, chown, addr=addr) + await self._check_execute_command(host, chown, addr=addr) chmod = RemoteCommand(Executables.CHMOD, [str(644), tmp_path]) - await execute_method(host, chmod, addr=addr) + await self._check_execute_command(host, chmod, addr=addr) with NamedTemporaryFile(prefix='cephadm-write-remote-file-') as f: os.fchmod(f.fileno(), 0o600) f.write(content) @@ -544,12 +476,12 @@ class SSHManager: Executables.CHOWN, ['-R', str(uid) + ':' + str(gid), tmp_path] ) - await execute_method(host, chown, addr=addr) + await self._check_execute_command(host, chown, addr=addr) if mode is not None: chmod = RemoteCommand(Executables.CHMOD, [oct(mode)[2:], tmp_path]) - await execute_method(host, chmod, addr=addr) + await self._check_execute_command(host, chmod, addr=addr) mv = RemoteCommand(Executables.MV, ['-Z', tmp_path, path]) - await execute_method(host, mv, addr=addr) + await self._check_execute_command(host, mv, addr=addr) except Exception as e: msg = f"Unable to write {host}:{path}: {e}" logger.exception(msg) @@ -599,11 +531,10 @@ class SSHManager: uid: Optional[int] = None, gid: Optional[int] = None, addr: Optional[str] = None, - bypass_cephadm_exec: Optional[bool] = False, ) -> None: with self.mgr.async_timeout_handler(host, f'writing file {path}'): self.mgr.wait_async(self._write_remote_file( - host, path, content, mode, uid, gid, addr, bypass_cephadm_exec)) + host, path, content, mode, uid, gid, addr)) async def _reset_con(self, host: str) -> None: conn = self.cons.get(host) diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index 9bc1148941a..b97b7f175f2 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -2277,9 +2277,9 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_all_client_files() # Make sure both ceph conf locations (default and per fsid) are called _write_file.assert_has_calls([mock.call('test', '/etc/ceph/ceph.conf', b'', - 0o644, 0, 0, None, False), + 0o644, 0, 0, None), mock.call('test', '/var/lib/ceph/fsid/config/ceph.conf', b'', - 0o644, 0, 0, None, False)] + 0o644, 0, 0, None)] ) ceph_conf_files = cephadm_module.cache.get_host_client_files('test') assert len(ceph_conf_files) == 2 @@ -2291,10 +2291,10 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_all_client_files() _write_file.assert_has_calls([mock.call('test', '/etc/ceph/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None, False), + b'[mon]\nk=v\n', 0o644, 0, 0, None), mock.call('test', '/var/lib/ceph/fsid/config/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None, False)]) + b'[mon]\nk=v\n', 0o644, 0, 0, None)]) # reload cephadm_module.cache.last_client_files = {} cephadm_module.cache.load() diff --git a/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py b/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py index 07bfd4befb4..f525b217c01 100644 --- a/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py +++ b/src/pybind/mgr/cephadm/tests/test_ssh_hardening.py @@ -142,46 +142,3 @@ class TestSudoHardening: assert 'sudo' not in called_args assert 'check-host' in called_args assert '--expect-hostname test-host' in called_args - - def test_execute_cephadm_exec_with_hardening(self, setup_sudo_hardening): - """Test cephadm exec with SSH hardening enabled.""" - ssh_manager = SSHManager(setup_sudo_hardening) - mock_conn = AsyncMock() - mock_conn.run.return_value = mock.Mock( - stdout='success', stderr='', returncode=0 - ) - - with mock.patch.object(setup_sudo_hardening.ssh, '_remote_connection', - return_value=mock_conn): - cmd = RemoteCommand(Executables.LS, ['-la', '/tmp']) - out, err, code = ssh_manager.execute_cephadm_exec('test-host', cmd) - - called_args = mock_conn.run.call_args[0][0] - assert called_args.startswith('sudo /usr/libexec/cephadm_invoker.py') - assert setup_sudo_hardening.cephadm_binary_path in called_args - assert 'exec' in called_args and '--command' in called_args - assert (out, err, code) == ('success', '', 0) - - def test_execute_cephadm_exec_without_hardening(self, cephadm_module): - """Test cephadm exec with SSH hardening disabled.""" - cephadm_module.sudo_hardening = False - cephadm_module.ssh_user = 'cephadm' - cephadm_module.cephadm_binary_path = '/var/lib/ceph/fsid/cephadm.abc123' - ssh_manager = SSHManager(cephadm_module) - - mock_conn = AsyncMock() - mock_conn.run.return_value = mock.Mock( - stdout='success', stderr='', returncode=0 - ) - - with mock.patch.object(cephadm_module.ssh, '_remote_connection', - return_value=mock_conn): - cmd = RemoteCommand(Executables.LS, ['-la', '/tmp']) - out, err, code = ssh_manager.execute_cephadm_exec('test-host', cmd) - - called_args = mock_conn.run.call_args[0][0] - # When hardening is disabled, cephadm exec should use cephadm binary directly (not invoker) - assert cephadm_module.cephadm_binary_path in called_args - assert '/usr/libexec/cephadm_invoker.py' not in called_args - assert 'exec' in called_args and '--command' in called_args - assert (out, err, code) == ('success', '', 0) diff --git a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py index 2eba05dfa57..9db971f6f21 100644 --- a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py @@ -80,24 +80,6 @@ class FakeMgr: {'y': 'y'}).to_json()}) return '' - def async_timeout_handler(self, host='', cmd='', timeout=None): - """Mock async_timeout_handler for tests""" - from contextlib import contextmanager - - @contextmanager - def handler(): - yield - return handler() - - def wait_async(self, coro, timeout=None): - """Mock wait_async for tests - just run the coroutine""" - import asyncio - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - class TestTunedProfiles: tspec1 = TunedProfileSpec('p1', @@ -146,19 +128,19 @@ class TestTunedProfiles: ] _write_profiles.assert_has_calls(calls, any_order=True) - @mock.patch('cephadm.ssh.SSHManager.check_execute_cephadm_exec') - def test_rm_stray_tuned_profiles(self, _check_execute_cephadm_exec): + @mock.patch('cephadm.ssh.SSHManager.check_execute_command') + def test_rm_stray_tuned_profiles(self, _check_execute_command): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test, going to use host "a" and put 4 cephadm generated # profiles "p1" "p2", "p3" and "who" only two of which should be there ("p1", "p2") # as well as a file not generated by cephadm. Only the "p3" and "who" # profiles should be removed from the host. This should total to 4 - # calls to check_execute_cephadm_exec, 1 "ls", 2 "rm", and 1 "sysctl --system" - _check_execute_cephadm_exec.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', - 'p2-cephadm-tuned-profile.conf', - 'p3-cephadm-tuned-profile.conf', - 'who-cephadm-tuned-profile.conf', - 'dont-touch-me']) + # calls to check_execute_command, 1 "ls", 2 "rm", and 1 "sysctl --system" + _check_execute_command.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', + 'p2-cephadm-tuned-profile.conf', + 'p3-cephadm-tuned-profile.conf', + 'who-cephadm-tuned-profile.conf', + 'dont-touch-me']) mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], @@ -187,16 +169,16 @@ class TestTunedProfiles: 'a', RemoteCommand(Executables.SYSCTL, ['--system']) ), ] - _check_execute_cephadm_exec.assert_has_calls(calls, any_order=True) + _check_execute_command.assert_has_calls(calls, any_order=True) - @mock.patch('cephadm.ssh.SSHManager.check_execute_cephadm_exec') + @mock.patch('cephadm.ssh.SSHManager.check_execute_command') @mock.patch('cephadm.ssh.SSHManager.write_remote_file') - def test_write_tuned_profiles(self, _write_remote_file, _check_execute_cephadm_exec): + def test_write_tuned_profiles(self, _write_remote_file, _check_execute_command): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test we will use host "a" and have it so host_needs_tuned_profile_update # returns True for p2 and False for p1 (see FakeCache class). So we should see # 2 ssh calls, one to write p2, one to run sysctl --system - _check_execute_cephadm_exec.return_value = 'success' + _check_execute_command.return_value = 'success' _write_remote_file.return_value = 'success' mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], @@ -204,7 +186,7 @@ class TestTunedProfiles: profiles) tp = TunedProfileUtils(mgr) tp._write_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) - _check_execute_cephadm_exec.assert_called_with( + _check_execute_command.assert_called_with( 'a', RemoteCommand(Executables.SYSCTL, ['--system']) ) _write_remote_file.assert_called_with( diff --git a/src/pybind/mgr/cephadm/tuned_profiles.py b/src/pybind/mgr/cephadm/tuned_profiles.py index 5dbc2c102d2..7a37d937904 100644 --- a/src/pybind/mgr/cephadm/tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tuned_profiles.py @@ -73,7 +73,7 @@ class TunedProfileUtils(): if self.mgr.cache.is_host_unreachable(host): return cmd = ssh.RemoteCommand(ssh.Executables.LS, [SYSCTL_DIR]) - found_files = self.mgr.ssh.check_execute_cephadm_exec(host, cmd, log_command=self.mgr.log_refresh_metadata).split('\n') + found_files = self.mgr.ssh.check_execute_command(host, cmd, log_command=self.mgr.log_refresh_metadata).split('\n') found_files = [s.strip() for s in found_files] profile_names: List[str] = sum([[*p] for p in profiles], []) # extract all profiles names profile_names = list(set(profile_names)) # remove duplicates @@ -85,10 +85,10 @@ class TunedProfileUtils(): if file not in expected_files: logger.info(f'Removing stray tuned profile file {file}') cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', f'{SYSCTL_DIR}/{file}']) - self.mgr.ssh.check_execute_cephadm_exec(host, cmd) + self.mgr.ssh.check_execute_command(host, cmd) updated = True if updated: - self.mgr.ssh.check_execute_cephadm_exec(host, SYSCTL_SYSTEM_CMD) + self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) def _write_tuned_profiles(self, host: str, profiles: List[Dict[str, str]]) -> None: if self.mgr.cache.is_host_unreachable(host): @@ -102,5 +102,5 @@ class TunedProfileUtils(): self.mgr.ssh.write_remote_file(host, profile_filename, content.encode('utf-8')) updated = True if updated: - self.mgr.ssh.check_execute_cephadm_exec(host, SYSCTL_SYSTEM_CMD) + self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) self.mgr.cache.last_tuned_profile_update[host] = datetime_now() From 07edf265cb61fa1bdb492be405f7847620b6453f Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 8 May 2026 16:54:43 +0530 Subject: [PATCH 167/596] cephadm: Added cephadm check-online command for offline host watcher Added a `cephadm check-online` subcommand that returns 0, and use it from the mgr cephadm offline host watcher via `CephadmServe._run_cephadm` instead of SSHing `true` directly. Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/man/8/cephadm.rst | 13 ++++++++++++- src/cephadm/cephadm.py | 9 +++++++++ src/pybind/mgr/cephadm/offline_watcher.py | 9 ++++++--- src/pybind/mgr/cephadm/tests/test_ssh.py | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index 44d3beafb20..c290bf7712d 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -90,6 +90,8 @@ Synopsis | **cephadm** **check-host** [-h] [--expect-hostname EXPECT_HOSTNAME] +| **cephadm** **check-online** + | **cephadm** **prepare-host** | **cephadm** **add-repo** [-h] [--release RELEASE] [--version VERSION] @@ -289,6 +291,15 @@ Arguments: * [--expect-hostname EXPECT_HOSTNAME] Check that hostname matches an expected value +check-online +------------ + +check that the host is online by running ``true`` locally. + +This command is primarily intended for cephadm internals (for example, the +offline host watcher), rather than direct operator workflows. + + deploy ------ diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index ba246ab7c40..2365054f42d 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4454,6 +4454,10 @@ def command_check_host(ctx: CephadmContext) -> None: ################################## +def command_check_online(ctx: CephadmContext) -> int: + return 0 + + def command_prepare_host(ctx: CephadmContext) -> None: logger.info('Verifying podman|docker is present...') pkg = None @@ -5597,6 +5601,10 @@ def _get_parser(): '--expect-hostname', help='Check that hostname matches an expected value') + parser_check_online = subparsers.add_parser( + 'check-online', help='return true to indicate host is running') + parser_check_online.set_defaults(func=command_check_online) + parser_prepare_host = subparsers.add_parser( 'prepare-host', help='prepare a host for cephadm use') parser_prepare_host.set_defaults(func=command_prepare_host) @@ -5832,6 +5840,7 @@ def main() -> None: if ctx.func not in \ [ command_check_host, + command_check_online, command_prepare_host, command_add_repo, command_rm_repo, diff --git a/src/pybind/mgr/cephadm/offline_watcher.py b/src/pybind/mgr/cephadm/offline_watcher.py index 4aa07e2f584..c8bfa4cb3d1 100644 --- a/src/pybind/mgr/cephadm/offline_watcher.py +++ b/src/pybind/mgr/cephadm/offline_watcher.py @@ -4,7 +4,8 @@ from typing import List, Optional, TYPE_CHECKING import multiprocessing as mp import threading -from . import ssh +from .serve import CephadmServe +from .utils import cephadmNoImage if TYPE_CHECKING: from cephadm.module import CephadmOrchestrator @@ -40,8 +41,10 @@ class OfflineHostWatcher(threading.Thread): def check_host(self, host: str) -> None: if host not in self.mgr.offline_hosts: try: - rcmd = ssh.RemoteCommand(ssh.Executables.TRUE) - self.mgr.ssh.check_execute_command(host, rcmd, log_command=self.mgr.log_refresh_metadata) + with self.mgr.async_timeout_handler(host, 'cephadm check-online'): + self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( + host, cephadmNoImage, 'check-online', [], + no_fsid=True, log_output=self.mgr.log_refresh_metadata)) except Exception: logger.debug(f'OfflineHostDetector: detected {host} to be offline') # kick serve loop in case corrective action must be taken for offline host diff --git a/src/pybind/mgr/cephadm/tests/test_ssh.py b/src/pybind/mgr/cephadm/tests/test_ssh.py index 937e3156760..ebb80b18b94 100644 --- a/src/pybind/mgr/cephadm/tests/test_ssh.py +++ b/src/pybind/mgr/cephadm/tests/test_ssh.py @@ -21,6 +21,7 @@ from ceph.deployment.hostspec import HostSpec from cephadm import CephadmOrchestrator from cephadm.serve import CephadmServe from cephadm.tests.fixtures import with_host, wait, async_side_effect +from cephadm.utils import cephadmNoImage from orchestrator import OrchestratorError @@ -111,3 +112,17 @@ def test_remote_command(): '-rf', '/tmp/blat', ] + + +@mock.patch("cephadm.offline_watcher.CephadmServe._run_cephadm") +def test_offline_watcher_uses_cephadm_check_online(run_cephadm, cephadm_module): + run_cephadm.side_effect = async_side_effect(([''], [''], 0)) + + with with_host(cephadm_module, 'test'): + run_cephadm.reset_mock() + cephadm_module.offline_watcher.check_host('test') + + run_cephadm.assert_called_once_with( + 'test', cephadmNoImage, 'check-online', [], + no_fsid=True, log_output=cephadm_module.log_refresh_metadata + ) From c40c6296a1cf3221e9da4ce7666255c017d7e202 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 11 May 2026 10:33:48 +0530 Subject: [PATCH 168/596] cephadm: added remove-file command for client file cleanup Add a cephadm remove-file subcommand that deletes only regular files. Have the mgr call it from CephadmServe._write_client_files when pruning stale client keyrings instead of running ssh rm, so removal goes through the same cephadm/invoker path as other host operations. Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/man/8/cephadm.rst | 17 ++++++++- src/cephadm/cephadm.py | 37 ++++++++++++++++++- src/cephadm/tests/test_cephadm.py | 38 ++++++++++++++++++++ src/pybind/mgr/cephadm/serve.py | 5 +-- src/pybind/mgr/cephadm/tests/test_cephadm.py | 16 ++++++++- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index c290bf7712d..1d352ded429 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -92,6 +92,8 @@ Synopsis | **cephadm** **check-online** +| **cephadm** **remove-file** [-h] [--fsid FSID] --path PATH + | **cephadm** **prepare-host** | **cephadm** **add-repo** [-h] [--release RELEASE] [--version VERSION] @@ -300,6 +302,19 @@ This command is primarily intended for cephadm internals (for example, the offline host watcher), rather than direct operator workflows. +remove-file +----------- + +Remove a regular file on the local host. Missing paths are ignored. +Refuses directories and symbolic links, only plain files are +removed. + +Arguments: + +* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) +* --path PATH absolute path of the file to remove (required) + + deploy ------ diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 2365054f42d..d49e96ea45b 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4643,6 +4643,29 @@ def command_gather_facts(ctx: CephadmContext) -> None: print(host.dump()) +def command_remove_file(ctx: CephadmContext) -> int: + """Remove a regular file on the host + """ + norm = Path(os.path.normpath(str(Path(ctx.remove_file_path).expanduser()))) + + if not norm.is_absolute(): + raise Error(f'Can not remove non-absolute path: {norm}') + try: + if not norm.exists(): + return 0 + # Refuse symlinks explicitly because is_file() follows them + if norm.is_symlink() or not norm.is_file(): + raise Error(f'Can not remove non-regular file: {norm}') + + norm.unlink() + + except FileNotFoundError: + return 0 + except OSError as e: + raise Error(f'failed to remove {norm}: {e}') + return 0 + + ################################## @@ -5697,6 +5720,18 @@ def _get_parser(): 'gather-facts', help='gather and return host related information (JSON format)') parser_gather_facts.set_defaults(func=command_gather_facts) + parser_remove_file = subparsers.add_parser( + 'remove-file', help='remove a file on the host') + parser_remove_file.set_defaults(func=command_remove_file) + parser_remove_file.add_argument( + '--fsid', + help='cluster FSID') + parser_remove_file.add_argument( + '--path', + required=True, + dest='remove_file_path', + help='absolute path of the file to remove') + parser_maintenance = subparsers.add_parser( 'host-maintenance', help='Manage the maintenance state of a host') parser_maintenance.add_argument( @@ -5845,7 +5880,7 @@ def main() -> None: command_add_repo, command_rm_repo, command_install, - command_bootstrap + command_bootstrap, ]: check_container_engine(ctx) # command handler diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index 7d2402d8b9b..efba1cf777e 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -106,6 +106,44 @@ class TestCephAdm(object): _attempt_bind.side_effect = os_error assert port_in_use(empty_ctx, _cephadm.EndPoint('0.0.0.0', 9100)) == False + def test_command_remove_file(self, cephadm_fs): + rm_path = '/tmp/cephadm-remove-file-test' + cephadm_fs.create_file(rm_path, contents='x') + with with_cephadm_ctx( + ['remove-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--path', rm_path] + ) as ctx: + assert _cephadm.command_remove_file(ctx) == 0 + assert not cephadm_fs.exists(rm_path) + + def test_command_remove_file_missing_ok(self, cephadm_fs): + missing = '/tmp/cephadm-remove-file-missing' + with with_cephadm_ctx( + ['remove-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--path', missing] + ) as ctx: + assert _cephadm.command_remove_file(ctx) == 0 + + def test_command_remove_file_refuses_directory(self, cephadm_fs): + dpath = '/tmp/cephadm-remove-file-isdir' + cephadm_fs.create_dir(dpath) + with with_cephadm_ctx( + ['remove-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--path', dpath] + ) as ctx: + with pytest.raises(_cephadm.Error, match='Can not remove non-regular file'): + _cephadm.command_remove_file(ctx) + assert cephadm_fs.exists(dpath) + + def test_command_remove_file_refuses_symlink(self, cephadm_fs): + target = '/tmp/cephadm-remove-file-symtarget' + link = '/tmp/cephadm-remove-file-symlink' + cephadm_fs.create_file(target, contents='x') + cephadm_fs.create_symlink(link, target) + with with_cephadm_ctx( + ['remove-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--path', link] + ) as ctx: + with pytest.raises(_cephadm.Error, match='Can not remove non-regular file'): + _cephadm.command_remove_file(ctx) + assert cephadm_fs.exists(link) + @mock.patch('cephadm.socket.socket.bind') @mock.patch('cephadm.logger') def test_port_in_use_special_cases(self, _logger, _bind): diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index e45ec846df2..7d992260717 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1399,8 +1399,9 @@ class CephadmServe: if path == '/etc/ceph/ceph.conf': continue self.log.info(f'Removing {host}:{path}') - cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', path]) - self.mgr.ssh.check_execute_command(host, cmd) + with self.mgr.async_timeout_handler(host, f'cephadm remove-file ({path})'): + self.mgr.wait_async(self._run_cephadm( + host, cephadmNoImage, 'remove-file', ['--path', path])) updated_files = True self.mgr.cache.removed_client_file(host, path) if updated_files: diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index b97b7f175f2..bb5933152a7 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -15,7 +15,7 @@ from cephadm.inventory import ( ) from cephadm.services.osd import OSD, OSDRemovalQueue, OsdIdClaims from cephadm.services.nvmeof import NvmeofService -from cephadm.utils import SpecialHostLabels +from cephadm.utils import SpecialHostLabels, cephadmNoImage try: from typing import List @@ -2346,6 +2346,20 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_client_files({}, 'host2') CephadmServe(cephadm_module)._write_client_files({}, 'host3') + @mock.patch('cephadm.serve.CephadmServe._run_cephadm', new_callable=mock.AsyncMock) + def test_write_client_files_remove_calls_cephadm_remove_file(self, _run_cephadm, cephadm_module): + _run_cephadm.return_value = ([''], [''], 0) + cephadm_module.inventory.add_host(HostSpec('host1', '10.0.0.1')) + cephadm_module.cache.prime_empty_host('host1') + stale = '/var/lib/ceph/fsid/config/foo.keyring' + cephadm_module.cache.update_client_file('host1', stale, 'digest', 0o600, 0, 0) + CephadmServe(cephadm_module)._write_client_files({'host1': {}}, 'host1') + _run_cephadm.assert_called_once() + pos_args = _run_cephadm.call_args[0] + # Bound method mock: call_args do not include self. + assert pos_args[0:4] == ('host1', cephadmNoImage, 'remove-file', ['--path', stale]) + assert stale not in cephadm_module.cache.get_host_client_files('host1') + @mock.patch('cephadm.CephadmOrchestrator.mon_command') @mock.patch("cephadm.inventory.HostCache.get_host_client_files") def test_dont_write_etc_ceph_client_files_when_turned_off(self, _get_client_files, _mon_command, cephadm_module): From 56895ee07b9cf9d23ae46d7d71e7316cd3320227 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 11 May 2026 11:42:15 +0530 Subject: [PATCH 169/596] cephadm: added sysctl-dir command for tuned profiles, use remove-file for strays Added cephadm sysctl-dir with mutually exclusive --list (sorted basenames under /etc/sysctl.d) and --apply-system (sysctl --system). The cephadm mgr tuned profile logic calls sysctl-dir for listing and reload, and remove-file for stray *-cephadm-tuned-profile.conf files Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/man/8/cephadm.rst | 16 +++- src/cephadm/cephadm.py | 41 +++++++++++ src/cephadm/tests/test_cephadm.py | 40 ++++++++++ .../mgr/cephadm/tests/test_tuned_profiles.py | 73 +++++++++---------- src/pybind/mgr/cephadm/tuned_profiles.py | 38 ++++++++-- 5 files changed, 162 insertions(+), 46 deletions(-) diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index 1d352ded429..35e5e8fe99f 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,sysctl-dir,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -94,6 +94,8 @@ Synopsis | **cephadm** **remove-file** [-h] [--fsid FSID] --path PATH +| **cephadm** **sysctl-dir** [-h] [--fsid FSID] (--list | --apply-system) + | **cephadm** **prepare-host** | **cephadm** **add-repo** [-h] [--release RELEASE] [--version VERSION] @@ -315,6 +317,18 @@ Arguments: * --path PATH absolute path of the file to remove (required) +sysctl-dir +---------- + +List basenames under ``/etc/sysctl.d`` or run ``sysctl --system`` on the local host. + +Arguments: + +* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) +* --list print one directory entry per line (sorted) +* --apply-system reload sysctl settings from all configuration paths + + deploy ------ diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index d49e96ea45b..20f26e48d25 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4666,6 +4666,25 @@ def command_remove_file(ctx: CephadmContext) -> int: return 0 +def command_sysctl_dir(ctx: CephadmContext) -> int: + """List basenames under sysctl.d or run sysctl --system""" + action = ctx.sysctl_dir_action + sysctl_dir = Path(SYSCTL_DIR) + if action == 'list': + if not sysctl_dir.is_dir(): + raise Error(f'Not a directory: {SYSCTL_DIR}') + for name in sorted(p.name for p in sysctl_dir.iterdir()): + print(name) + return 0 + if action == 'apply_system': + _out, _err, code = call( + ctx, ['sysctl', '--system'], verbosity=CallVerbosity.DEBUG) + if code: + raise Error(f'sysctl --system failed with code {code}: {_err}') + return 0 + raise Error('sysctl-dir: no action specified') + + ################################## @@ -5732,6 +5751,28 @@ def _get_parser(): dest='remove_file_path', help='absolute path of the file to remove') + parser_sysctl_dir = subparsers.add_parser( + 'sysctl-dir', + help='list entries in sysctl.d or run sysctl --system') + parser_sysctl_dir.set_defaults(func=command_sysctl_dir) + parser_sysctl_dir.add_argument( + '--fsid', + help='cluster FSID') + _sysctl_dir_action = parser_sysctl_dir.add_mutually_exclusive_group( + required=True) + _sysctl_dir_action.add_argument( + '--list', + dest='sysctl_dir_action', + action='store_const', + const='list', + help=f'print one basename per line from {SYSCTL_DIR}') + _sysctl_dir_action.add_argument( + '--apply-system', + dest='sysctl_dir_action', + action='store_const', + const='apply_system', + help='reload sysctl settings from all config paths (sysctl --system)') + parser_maintenance = subparsers.add_parser( 'host-maintenance', help='Manage the maintenance state of a host') parser_maintenance.add_argument( diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index efba1cf777e..1644ceca0ef 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -144,6 +144,46 @@ class TestCephAdm(object): _cephadm.command_remove_file(ctx) assert cephadm_fs.exists(link) + def test_command_sysctl_dir_list(self, cephadm_fs, capsys): + from cephadmlib.constants import SYSCTL_DIR + cephadm_fs.create_dir(SYSCTL_DIR) + cephadm_fs.create_file(os.path.join(SYSCTL_DIR, 'c.conf')) + cephadm_fs.create_file(os.path.join(SYSCTL_DIR, 'a.conf')) + with with_cephadm_ctx( + ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] + ) as ctx: + assert _cephadm.command_sysctl_dir(ctx) == 0 + assert capsys.readouterr().out.splitlines() == ['a.conf', 'c.conf'] + + def test_command_sysctl_dir_list_missing_dir(self, cephadm_fs): + import shutil + from cephadmlib.constants import SYSCTL_DIR + # cephadm_fs already has /etc (e.g. from UNIT_DIR). Only sysctl.d must be absent. + if cephadm_fs.exists(SYSCTL_DIR): + shutil.rmtree(SYSCTL_DIR) + assert not cephadm_fs.exists(SYSCTL_DIR) + with with_cephadm_ctx( + ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] + ) as ctx: + with pytest.raises(_cephadm.Error, match='Not a directory'): + _cephadm.command_sysctl_dir(ctx) + + def test_command_sysctl_dir_apply_system(self, cephadm_fs): + with with_cephadm_ctx( + ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'] + ) as ctx: + assert _cephadm.command_sysctl_dir(ctx) == 0 + + def test_command_sysctl_dir_apply_system_failure(self, cephadm_fs): + # Do not let with_cephadm_ctx re-patch cephadm.call back to success (exit 0). + with mock.patch('cephadm.call', return_value=('out', 'sysctl failed', 1)): + with with_cephadm_ctx( + ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'], + mock_cephadm_call_fn=False, + ) as ctx: + with pytest.raises(_cephadm.Error, match='sysctl --system failed'): + _cephadm.command_sysctl_dir(ctx) + @mock.patch('cephadm.socket.socket.bind') @mock.patch('cephadm.logger') def test_port_in_use_special_cases(self, _logger, _bind): diff --git a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py index 9db971f6f21..b1660d9e957 100644 --- a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py @@ -1,11 +1,14 @@ +import asyncio import pytest import json +from contextlib import contextmanager from tests import mock from cephadm.tuned_profiles import TunedProfileUtils, SYSCTL_DIR +from cephadm.utils import cephadmNoImage from cephadm.inventory import TunedProfileStore from ceph.utils import datetime_now from ceph.deployment.service_spec import TunedProfileSpec, PlacementSpec -from cephadm.ssh import SSHManager, RemoteCommand, Executables +from cephadm.ssh import SSHManager from orchestrator import HostSpec from typing import List, Dict @@ -67,6 +70,13 @@ class FakeMgr: self.offline_hosts = [] self.log_refresh_metadata = False + @contextmanager + def async_timeout_handler(self, _host, _msg): + yield + + def wait_async(self, coro, timeout=None): + return asyncio.run(coro) + def set_store(self, what: str, value: str): raise SaveError(f'{what}: {value}') @@ -128,57 +138,48 @@ class TestTunedProfiles: ] _write_profiles.assert_has_calls(calls, any_order=True) - @mock.patch('cephadm.ssh.SSHManager.check_execute_command') - def test_rm_stray_tuned_profiles(self, _check_execute_command): + @mock.patch('cephadm.tuned_profiles.CephadmServe._run_cephadm', new_callable=mock.AsyncMock) + @mock.patch('cephadm.tuned_profiles.TunedProfileUtils._sysctl_dir_apply_system') + @mock.patch('cephadm.tuned_profiles.TunedProfileUtils._sysctl_dir_list') + def test_rm_stray_tuned_profiles( + self, _sysctl_dir_list, _sysctl_dir_apply_system, _run_cephadm): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test, going to use host "a" and put 4 cephadm generated # profiles "p1" "p2", "p3" and "who" only two of which should be there ("p1", "p2") # as well as a file not generated by cephadm. Only the "p3" and "who" - # profiles should be removed from the host. This should total to 4 - # calls to check_execute_command, 1 "ls", 2 "rm", and 1 "sysctl --system" - _check_execute_command.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', - 'p2-cephadm-tuned-profile.conf', - 'p3-cephadm-tuned-profile.conf', - 'who-cephadm-tuned-profile.conf', - 'dont-touch-me']) + # profiles should be removed via cephadm remove-file. List/apply use cephadm. + _sysctl_dir_list.return_value = '\n'.join(['p1-cephadm-tuned-profile.conf', + 'p2-cephadm-tuned-profile.conf', + 'p3-cephadm-tuned-profile.conf', + 'who-cephadm-tuned-profile.conf', + 'dont-touch-me']) + _run_cephadm.return_value = ([''], [''], 0) mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], profiles) tp = TunedProfileUtils(mgr) tp._remove_stray_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) - calls = [ + _sysctl_dir_list.assert_called_once_with('a') + _sysctl_dir_apply_system.assert_called_once_with('a') + rm_calls = [ mock.call( - 'a', RemoteCommand(Executables.LS, [SYSCTL_DIR]), log_command=False - ), + 'a', cephadmNoImage, 'remove-file', + ['--path', f'{SYSCTL_DIR}/p3-cephadm-tuned-profile.conf']), mock.call( - 'a', - RemoteCommand( - Executables.RM, - ['-f', f'{SYSCTL_DIR}/p3-cephadm-tuned-profile.conf'] - ) - ), - mock.call( - 'a', - RemoteCommand( - Executables.RM, - ['-f', f'{SYSCTL_DIR}/who-cephadm-tuned-profile.conf'] - ) - ), - mock.call( - 'a', RemoteCommand(Executables.SYSCTL, ['--system']) - ), + 'a', cephadmNoImage, 'remove-file', + ['--path', f'{SYSCTL_DIR}/who-cephadm-tuned-profile.conf']), ] - _check_execute_command.assert_has_calls(calls, any_order=True) + _run_cephadm.assert_has_calls(rm_calls, any_order=True) + assert _run_cephadm.call_count == 2 - @mock.patch('cephadm.ssh.SSHManager.check_execute_command') + @mock.patch('cephadm.tuned_profiles.TunedProfileUtils._sysctl_dir_apply_system') @mock.patch('cephadm.ssh.SSHManager.write_remote_file') - def test_write_tuned_profiles(self, _write_remote_file, _check_execute_command): + def test_write_tuned_profiles(self, _write_remote_file, _sysctl_dir_apply_system): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test we will use host "a" and have it so host_needs_tuned_profile_update # returns True for p2 and False for p1 (see FakeCache class). So we should see - # 2 ssh calls, one to write p2, one to run sysctl --system - _check_execute_command.return_value = 'success' + # one write for p2 and sysctl-dir --apply-system via cephadm. _write_remote_file.return_value = 'success' mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], @@ -186,9 +187,7 @@ class TestTunedProfiles: profiles) tp = TunedProfileUtils(mgr) tp._write_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) - _check_execute_command.assert_called_with( - 'a', RemoteCommand(Executables.SYSCTL, ['--system']) - ) + _sysctl_dir_apply_system.assert_called_once_with('a') _write_remote_file.assert_called_with( 'a', f'{SYSCTL_DIR}/p2-cephadm-tuned-profile.conf', tp._profile_to_str(self.tspec2).encode('utf-8')) diff --git a/src/pybind/mgr/cephadm/tuned_profiles.py b/src/pybind/mgr/cephadm/tuned_profiles.py index 7a37d937904..d3f07342beb 100644 --- a/src/pybind/mgr/cephadm/tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tuned_profiles.py @@ -3,7 +3,8 @@ from typing import Dict, List, TYPE_CHECKING from ceph.utils import datetime_now from .schedule import HostAssignment from ceph.deployment.service_spec import ServiceSpec, TunedProfileSpec -from . import ssh +from .serve import CephadmServe +from .utils import cephadmNoImage if TYPE_CHECKING: from cephadm.module import CephadmOrchestrator @@ -12,13 +13,33 @@ logger = logging.getLogger(__name__) SYSCTL_DIR = '/etc/sysctl.d' -SYSCTL_SYSTEM_CMD = ssh.RemoteCommand(ssh.Executables.SYSCTL, ['--system']) +SYSCTL_DIR_CEPHADM_CMD = 'sysctl-dir' class TunedProfileUtils(): def __init__(self, mgr: "CephadmOrchestrator") -> None: self.mgr = mgr + def _sysctl_dir_list(self, host: str) -> str: + with self.mgr.async_timeout_handler(host, 'cephadm sysctl-dir --list'): + out, _err, _code = self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( + host, + cephadmNoImage, + SYSCTL_DIR_CEPHADM_CMD, + ['--list'], + log_output=self.mgr.log_refresh_metadata, + )) + return ''.join(out) + + def _sysctl_dir_apply_system(self, host: str) -> None: + with self.mgr.async_timeout_handler(host, 'cephadm sysctl-dir --apply-system'): + self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( + host, + cephadmNoImage, + SYSCTL_DIR_CEPHADM_CMD, + ['--apply-system'], + )) + def _profile_to_str(self, p: TunedProfileSpec) -> str: p_str = f'# created by cephadm\n# tuned profile "{p.profile_name}"\n\n' for k, v in p.settings.items(): @@ -72,8 +93,7 @@ class TunedProfileUtils(): """ if self.mgr.cache.is_host_unreachable(host): return - cmd = ssh.RemoteCommand(ssh.Executables.LS, [SYSCTL_DIR]) - found_files = self.mgr.ssh.check_execute_command(host, cmd, log_command=self.mgr.log_refresh_metadata).split('\n') + found_files = self._sysctl_dir_list(host).split('\n') found_files = [s.strip() for s in found_files] profile_names: List[str] = sum([[*p] for p in profiles], []) # extract all profiles names profile_names = list(set(profile_names)) # remove duplicates @@ -84,11 +104,13 @@ class TunedProfileUtils(): continue if file not in expected_files: logger.info(f'Removing stray tuned profile file {file}') - cmd = ssh.RemoteCommand(ssh.Executables.RM, ['-f', f'{SYSCTL_DIR}/{file}']) - self.mgr.ssh.check_execute_command(host, cmd) + path = f'{SYSCTL_DIR}/{file}' + with self.mgr.async_timeout_handler(host, f'cephadm remove-file ({path})'): + self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( + host, cephadmNoImage, 'remove-file', ['--path', path])) updated = True if updated: - self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) + self._sysctl_dir_apply_system(host) def _write_tuned_profiles(self, host: str, profiles: List[Dict[str, str]]) -> None: if self.mgr.cache.is_host_unreachable(host): @@ -102,5 +124,5 @@ class TunedProfileUtils(): self.mgr.ssh.write_remote_file(host, profile_filename, content.encode('utf-8')) updated = True if updated: - self.mgr.ssh.check_execute_command(host, SYSCTL_SYSTEM_CMD) + self._sysctl_dir_apply_system(host) self.mgr.cache.last_tuned_profile_update[host] = datetime_now() From 4d35c61283ca61b975f1cd44010bd24cc6bfdf02 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 14 May 2026 14:50:16 +0530 Subject: [PATCH 170/596] mgr/cephadm: adding cephadm deploy-file command for mgr file writes Add a cephadm "deploy-file" subcommand that reads raw file bytes from stdin and atomically installs them at an absolute destination (--fsid, --path, optional --mode, --uid/--gid). Use it from the mgr for client conf/keyring sync and tuned profiles instead of SSHManager.write_remote_file. Keep staging the cephadm binary over SSH via _write_remote_file when sudo hardening is off, the invoker deploy_binary path is unchanged when hardening is on. SSH: pass encoding=None to asyncssh conn.run when stdin is bytes so binary payloads (deploy-file) are not UTF-8-encoded as str. Only add the input kwarg when stdin is not None. Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/man/8/cephadm.rst | 20 ++++- src/cephadm/cephadm.py | 57 +++++++++++++ src/cephadm/cephadmlib/file_utils.py | 4 +- src/cephadm/tests/test_cephadm.py | 41 +++++++++ src/pybind/mgr/cephadm/serve.py | 36 +++++++- src/pybind/mgr/cephadm/ssh.py | 34 ++++---- src/pybind/mgr/cephadm/tests/test_cephadm.py | 84 +++++++++++++++---- .../mgr/cephadm/tests/test_tuned_profiles.py | 8 +- src/pybind/mgr/cephadm/tuned_profiles.py | 8 +- 9 files changed, 248 insertions(+), 44 deletions(-) diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index 35e5e8fe99f..c8f2ba98d29 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,sysctl-dir,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,deploy-file,sysctl-dir,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -94,6 +94,9 @@ Synopsis | **cephadm** **remove-file** [-h] [--fsid FSID] --path PATH +| **cephadm** **deploy-file** [-h] [--fsid FSID] --path PATH [--mode MODE] +| [--uid UID] [--gid GID] + | **cephadm** **sysctl-dir** [-h] [--fsid FSID] (--list | --apply-system) | **cephadm** **prepare-host** @@ -317,6 +320,21 @@ Arguments: * --path PATH absolute path of the file to remove (required) +deploy-file +----------- + +Write or replace a file on the local host. The **entire file body** is read from +**standard input** as raw bytes (no encoding or line-ending translation). + +Arguments: + +* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) +* --path PATH absolute destination path for the file (required) +* [--mode MODE] octal file mode (for example ``644`` or ``0644``) +* [--uid UID] numeric owner user id (**must** be given together with ``--gid``) +* [--gid GID] numeric owner group id (**must** be given together with ``--uid``) + + sysctl-dir ---------- diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 20f26e48d25..11de13858b5 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4666,6 +4666,33 @@ def command_remove_file(ctx: CephadmContext) -> int: return 0 +@infer_fsid +def command_deploy_file(ctx: CephadmContext) -> int: + """Write or replace a host file from raw stdin bytes (for mgr-driven config sync).""" + dest = Path(ctx.deploy_file_path).expanduser() + if not dest.is_absolute(): + raise Error(f'deploy-file: destination must be an absolute path: {dest}') + + uid = ctx.deploy_file_uid + gid = ctx.deploy_file_gid + if (uid is None) != (gid is None): + raise Error('deploy-file: --uid and --gid must be given together') + + owner = (uid, gid) if uid is not None and gid is not None else None + perms = None + if ctx.deploy_file_mode is not None: + perms = int(str(ctx.deploy_file_mode), 8) + + dest.parent.mkdir(parents=True, mode=0o755, exist_ok=True) + try: + with write_new(dest, owner=owner, perms=perms, binary=True) as fh: + fh.write(sys.stdin.buffer.read()) + except Exception as e: + logger.exception('deploy-file: Failed to write file, exception: %s', e) + raise + return 0 + + def command_sysctl_dir(ctx: CephadmContext) -> int: """List basenames under sysctl.d or run sysctl --system""" action = ctx.sysctl_dir_action @@ -5751,6 +5778,36 @@ def _get_parser(): dest='remove_file_path', help='absolute path of the file to remove') + parser_deploy_file = subparsers.add_parser( + 'deploy-file', + help='write or replace a host file from stdin (raw bytes)') + parser_deploy_file.set_defaults(func=command_deploy_file) + parser_deploy_file.add_argument( + '--fsid', + help='cluster FSID') + parser_deploy_file.add_argument( + '--path', + required=True, + dest='deploy_file_path', + help='absolute destination path for the file') + parser_deploy_file.add_argument( + '--mode', + dest='deploy_file_mode', + default=None, + help='octal mode for the file (e.g. 644 or 0644)') + parser_deploy_file.add_argument( + '--uid', + type=int, + dest='deploy_file_uid', + default=None, + help='numeric owner uid (requires --gid)') + parser_deploy_file.add_argument( + '--gid', + type=int, + dest='deploy_file_gid', + default=None, + help='numeric owner gid (requires --uid)') + parser_sysctl_dir = subparsers.add_parser( 'sysctl-dir', help='list entries in sysctl.d or run sysctl --system') diff --git a/src/cephadm/cephadmlib/file_utils.py b/src/cephadm/cephadmlib/file_utils.py index 1cd12adf018..be1f899fa3e 100644 --- a/src/cephadm/cephadmlib/file_utils.py +++ b/src/cephadm/cephadmlib/file_utils.py @@ -24,6 +24,7 @@ def write_new( owner: Optional[Tuple[int, int]] = None, perms: Optional[int] = DEFAULT_MODE, encoding: Optional[str] = None, + binary: bool = False, ) -> Generator[IO, None, None]: """Write a new file in a robust manner, optionally specifying the owner, permissions, or encoding. This function takes care to never leave a file in @@ -38,8 +39,9 @@ def write_new( open_kwargs: Dict[str, Any] = {} if encoding: open_kwargs['encoding'] = encoding + file_mode = 'wb' if binary else 'w' try: - with open(tempname, 'w', **open_kwargs) as fh: + with open(tempname, file_mode, **open_kwargs) as fh: yield fh fh.flush() os.fsync(fh.fileno()) diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index 1644ceca0ef..f57b224468c 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -144,6 +144,47 @@ class TestCephAdm(object): _cephadm.command_remove_file(ctx) assert cephadm_fs.exists(link) + def test_command_deploy_file(self, cephadm_fs): + import io + fsid = '00000000-0000-0000-0000-0000deadbeef' + dest = '/etc/ceph/kube.conf' + cephadm_fs.create_dir('/etc/ceph') + content = b'hello\xff' + stdin_mock = mock.Mock() + stdin_mock.buffer = io.BytesIO(content) + with mock.patch('sys.stdin', stdin_mock): + with with_cephadm_ctx( + ['deploy-file', '--fsid', fsid, '--path', dest, '--mode', '600'] + ) as ctx: + assert _cephadm.command_deploy_file(ctx) == 0 + assert cephadm_fs.exists(dest) + with open(dest, 'rb') as f: + assert f.read() == content + + def test_command_deploy_file_rejects_relative_path(self, cephadm_fs): + import io + stdin_mock = mock.Mock() + stdin_mock.buffer = io.BytesIO(b'x') + with mock.patch('sys.stdin', stdin_mock): + with with_cephadm_ctx( + ['deploy-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', + '--path', 'relative/path.conf'] + ) as ctx: + with pytest.raises(_cephadm.Error, match='absolute path'): + _cephadm.command_deploy_file(ctx) + + def test_command_deploy_file_uid_gid_together(self, cephadm_fs): + import io + stdin_mock = mock.Mock() + stdin_mock.buffer = io.BytesIO(b'x') + with mock.patch('sys.stdin', stdin_mock): + with with_cephadm_ctx( + ['deploy-file', '--fsid', '00000000-0000-0000-0000-0000deadbeef', + '--path', '/etc/ceph/a', '--uid', '0'] + ) as ctx: + with pytest.raises(_cephadm.Error, match='together'): + _cephadm.command_deploy_file(ctx) + def test_command_sysctl_dir_list(self, cephadm_fs, capsys): from cephadmlib.constants import SYSCTL_DIR cephadm_fs.create_dir(SYSCTL_DIR) diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 7d992260717..568cda8e112 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1392,7 +1392,9 @@ class CephadmServe: if match: continue self.log.info(f'Updating {host}:{path}') - self.mgr.ssh.write_remote_file(host, path, content, mode, uid, gid) + with self.mgr.async_timeout_handler(host, f'cephadm deploy-file ({path})'): + self.mgr.wait_async(self._deploy_file_via_cephadm( + host, path, content, mode, uid, gid)) self.mgr.cache.update_client_file(host, path, digest, mode, uid, gid) updated_files = True for path in old_files.keys(): @@ -1695,7 +1697,7 @@ class CephadmServe: command: Union[str, List[str]], args: List[str], addr: Optional[str] = "", - stdin: Optional[str] = "", + stdin: Optional[Union[str, bytes]] = "", no_fsid: Optional[bool] = False, error_ok: Optional[bool] = False, image: Optional[str] = "", @@ -1783,7 +1785,10 @@ class CephadmServe: # agent has cephadm binary as an extra file which is # therefore passed over stdin. Even for debug logs it's too much if stdin and 'agent' not in str(entity): - self.log.debug('stdin: %s' % stdin) + if isinstance(stdin, bytes): + self.log.debug('stdin: ', len(stdin)) + else: + self.log.debug('stdin: %s', stdin) # If SSH hardening is enabled, call invoker directly without which python if self.mgr.sudo_hardening and self.mgr.invoker_path: @@ -1910,6 +1915,31 @@ class CephadmServe: return f"Host {host} failed to login to all registries" return None + async def _deploy_file_via_cephadm( + self, + host: str, + path: str, + content: bytes, + mode: Optional[int] = None, + uid: Optional[int] = None, + gid: Optional[int] = None, + addr: Optional[str] = None, + ) -> None: + """Write a host file using ``cephadm deploy-file`` (stdin = raw file bytes).""" + args: List[str] = ['--path', path] + if mode is not None: + args.extend(['--mode', oct(mode)[2:]]) + if uid is not None and gid is not None: + args.extend(['--uid', str(uid), '--gid', str(gid)]) + await self._run_cephadm( + host, + cephadmNoImage, + 'deploy-file', + args, + stdin=content, + addr=addr or '', + ) + async def _deploy_cephadm_binary(self, host: str, addr: Optional[str] = None) -> None: # Use tee (from coreutils) to create a copy of cephadm on the target machine self.log.info(f"Deploying cephadm binary to {host}") diff --git a/src/pybind/mgr/cephadm/ssh.py b/src/pybind/mgr/cephadm/ssh.py index 4e8cfcdb5e5..c6a57f9154b 100644 --- a/src/pybind/mgr/cephadm/ssh.py +++ b/src/pybind/mgr/cephadm/ssh.py @@ -8,7 +8,7 @@ from threading import Thread from contextlib import contextmanager from io import StringIO from shlex import quote -from typing import TYPE_CHECKING, Optional, List, Tuple, Dict, Iterator, TypeVar, Awaitable, Union +from typing import TYPE_CHECKING, Optional, List, Tuple, Dict, Iterator, TypeVar, Awaitable, Union, Any from orchestrator import OrchestratorError try: @@ -290,7 +290,7 @@ class SSHManager: async def _execute_command(self, host: str, cmd_components: RemoteCommand, - stdin: Optional[str] = None, + stdin: Optional[Union[str, bytes]] = None, addr: Optional[str] = None, log_command: Optional[bool] = True, ) -> Tuple[str, str, int]: @@ -311,7 +311,13 @@ class SSHManager: # Retry logic for transient connection/channel errors for attempt in range(self.SSH_RETRY_COUNT): try: - r = await conn.run(str(rcmd), input=stdin) + run_kw: Dict[str, Any] = {} + if stdin is not None: + run_kw['input'] = stdin + # Bytes stdin: use encoding=None (else asyncssh expects str). + if isinstance(stdin, bytes): + run_kw['encoding'] = None + r = await conn.run(str(rcmd), **run_kw) break # Success, exit retry loop # Handle retryable exceptions (connection/channel errors) # Note: handle these Exceptions otherwise you might get a weird error like @@ -404,7 +410,7 @@ class SSHManager: def execute_command(self, host: str, cmd: RemoteCommand, - stdin: Optional[str] = None, + stdin: Optional[Union[str, bytes]] = None, addr: Optional[str] = None, log_command: Optional[bool] = True ) -> Tuple[str, str, int]: @@ -414,7 +420,7 @@ class SSHManager: async def _check_execute_command(self, host: str, cmd: RemoteCommand, - stdin: Optional[str] = None, + stdin: Optional[Union[str, bytes]] = None, addr: Optional[str] = None, log_command: Optional[bool] = True ) -> str: @@ -428,7 +434,7 @@ class SSHManager: def check_execute_command(self, host: str, cmd: RemoteCommand, - stdin: Optional[str] = None, + stdin: Optional[Union[str, bytes]] = None, addr: Optional[str] = None, log_command: Optional[bool] = True, ) -> str: @@ -444,6 +450,9 @@ class SSHManager: gid: Optional[int] = None, addr: Optional[str] = None, ) -> None: + """This method will be used to only write cephadm binary when sudo_hardening is disbaled. + Other host files are written via ``cephadm deploy-file`` on the target host. + """ try: cephadm_tmp_dir = f"/tmp/cephadm-{self.mgr._cluster_fsid}" dirname = os.path.dirname(path) @@ -523,19 +532,6 @@ class SSHManager: except OSError: pass - def write_remote_file(self, - host: str, - path: str, - content: bytes, - mode: Optional[int] = None, - uid: Optional[int] = None, - gid: Optional[int] = None, - addr: Optional[str] = None, - ) -> None: - with self.mgr.async_timeout_handler(host, f'writing file {path}'): - self.mgr.wait_async(self._write_remote_file( - host, path, content, mode, uid, gid, addr)) - async def _reset_con(self, host: str) -> None: conn = self.cons.get(host) if conn: diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index bb5933152a7..fda43ce8f5e 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -2257,9 +2257,9 @@ class TestCephadm(object): @mock.patch("cephadm.ssh.SSHManager._remote_connection") @mock.patch("cephadm.ssh.SSHManager._execute_command") @mock.patch("cephadm.ssh.SSHManager._check_execute_command") - @mock.patch("cephadm.ssh.SSHManager._write_remote_file") - def test_etc_ceph(self, _write_file, check_execute_command, execute_command, remote_connection, cephadm_module): - _write_file.side_effect = async_side_effect(None) + @mock.patch("cephadm.serve.CephadmServe._deploy_file_via_cephadm", new_callable=mock.AsyncMock) + def test_etc_ceph(self, _deploy_file_via_cephadm, check_execute_command, execute_command, remote_connection, cephadm_module): + _deploy_file_via_cephadm.side_effect = async_side_effect(None) check_execute_command.side_effect = async_side_effect('') execute_command.side_effect = async_side_effect(('{}', '', 0)) remote_connection.side_effect = async_side_effect(mock.Mock()) @@ -2276,11 +2276,26 @@ class TestCephadm(object): CephadmServe(cephadm_module)._write_all_client_files() # Make sure both ceph conf locations (default and per fsid) are called - _write_file.assert_has_calls([mock.call('test', '/etc/ceph/ceph.conf', b'', - 0o644, 0, 0, None), - mock.call('test', '/var/lib/ceph/fsid/config/ceph.conf', b'', - 0o644, 0, 0, None)] - ) + _deploy_file_via_cephadm.assert_has_calls( + [ + mock.call( + 'test', + '/etc/ceph/ceph.conf', + b'', + 0o644, + 0, + 0, + ), + mock.call( + 'test', + '/var/lib/ceph/fsid/config/ceph.conf', + b'', + 0o644, + 0, + 0, + ), + ], + ) ceph_conf_files = cephadm_module.cache.get_host_client_files('test') assert len(ceph_conf_files) == 2 assert '/etc/ceph/ceph.conf' in ceph_conf_files @@ -2289,12 +2304,26 @@ class TestCephadm(object): # set extra config and expect that we deploy another ceph.conf cephadm_module._set_extra_ceph_conf('[mon]\nk=v') CephadmServe(cephadm_module)._write_all_client_files() - _write_file.assert_has_calls([mock.call('test', - '/etc/ceph/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None), - mock.call('test', - '/var/lib/ceph/fsid/config/ceph.conf', - b'[mon]\nk=v\n', 0o644, 0, 0, None)]) + _deploy_file_via_cephadm.assert_has_calls( + [ + mock.call( + 'test', + '/etc/ceph/ceph.conf', + b'[mon]\nk=v\n', + 0o644, + 0, + 0, + ), + mock.call( + 'test', + '/var/lib/ceph/fsid/config/ceph.conf', + b'[mon]\nk=v\n', + 0o644, + 0, + 0, + ), + ], + ) # reload cephadm_module.cache.last_client_files = {} cephadm_module.cache.load() @@ -2318,6 +2347,33 @@ class TestCephadm(object): assert f1_before_digest != f1_after_digest assert f2_before_digest != f2_after_digest + @mock.patch('cephadm.ssh.SSHManager._deploy_cephadm_binary_via_invoker', + new_callable=mock.AsyncMock) + @mock.patch('cephadm.ssh.SSHManager._write_remote_file', new_callable=mock.AsyncMock) + def test_deploy_cephadm_binary_uses_write_remote_file_without_sudo_hardening( + self, _write_remote_file, _deploy_via_invoker, cephadm_module): + """Without sudo hardening, the mgr stages the cephadm binary via _write_remote_file.""" + cephadm_module.sudo_hardening = False + cephadm_module.invoker_path = '' + cephadm_module.cephadm_binary_path = ( + f'/var/lib/ceph/{cephadm_module._cluster_fsid}/cephadm.deadbeef') + fake_bin = b'#!/usr/bin/fake-cephadm\n' + cephadm_module._cephadm = fake_bin + + _write_remote_file.side_effect = async_side_effect(None) + cephadm_module.wait_async( + CephadmServe(cephadm_module)._deploy_cephadm_binary('testhost')) + + _write_remote_file.assert_called_once() + _write_remote_file.assert_called_with( + 'testhost', + cephadm_module.cephadm_binary_path, + fake_bin, + addr=None, + mode=0o744, + ) + _deploy_via_invoker.assert_not_called() + @mock.patch("cephadm.inventory.HostCache.get_host_client_files") def test_dont_write_client_files_to_unreachable_hosts(self, _get_client_files, cephadm_module): cephadm_module.inventory.add_host(HostSpec('host1', '1.2.3.1')) # online diff --git a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py index b1660d9e957..0904d627bd9 100644 --- a/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tests/test_tuned_profiles.py @@ -174,13 +174,13 @@ class TestTunedProfiles: assert _run_cephadm.call_count == 2 @mock.patch('cephadm.tuned_profiles.TunedProfileUtils._sysctl_dir_apply_system') - @mock.patch('cephadm.ssh.SSHManager.write_remote_file') - def test_write_tuned_profiles(self, _write_remote_file, _sysctl_dir_apply_system): + @mock.patch('cephadm.serve.CephadmServe._deploy_file_via_cephadm', new_callable=mock.AsyncMock) + def test_write_tuned_profiles(self, _deploy_file_via_cephadm, _sysctl_dir_apply_system): profiles = {'p1': self.tspec1, 'p2': self.tspec2, 'p3': self.tspec3} # for this test we will use host "a" and have it so host_needs_tuned_profile_update # returns True for p2 and False for p1 (see FakeCache class). So we should see # one write for p2 and sysctl-dir --apply-system via cephadm. - _write_remote_file.return_value = 'success' + _deploy_file_via_cephadm.return_value = None mgr = FakeMgr(['a', 'b', 'c'], ['a', 'b', 'c'], [], @@ -188,7 +188,7 @@ class TestTunedProfiles: tp = TunedProfileUtils(mgr) tp._write_tuned_profiles('a', self.profiles_to_calls(tp, [self.tspec1, self.tspec2])) _sysctl_dir_apply_system.assert_called_once_with('a') - _write_remote_file.assert_called_with( + _deploy_file_via_cephadm.assert_called_with( 'a', f'{SYSCTL_DIR}/p2-cephadm-tuned-profile.conf', tp._profile_to_str(self.tspec2).encode('utf-8')) def test_dont_write_to_unreachable_hosts(self): diff --git a/src/pybind/mgr/cephadm/tuned_profiles.py b/src/pybind/mgr/cephadm/tuned_profiles.py index d3f07342beb..9223b1e0842 100644 --- a/src/pybind/mgr/cephadm/tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tuned_profiles.py @@ -120,8 +120,12 @@ class TunedProfileUtils(): for profile_name, content in p.items(): if self.mgr.cache.host_needs_tuned_profile_update(host, profile_name): logger.info(f'Writing tuned profile {profile_name} to host {host}') - profile_filename: str = f'{SYSCTL_DIR}/{profile_name}-cephadm-tuned-profile.conf' - self.mgr.ssh.write_remote_file(host, profile_filename, content.encode('utf-8')) + profile_filename: str = ( + f'{SYSCTL_DIR}/{profile_name}-cephadm-tuned-profile.conf') + with self.mgr.async_timeout_handler(host, f'cephadm deploy-file ({profile_filename})'): + self.mgr.wait_async( + CephadmServe(self.mgr)._deploy_file_via_cephadm( + host, profile_filename, content.encode('utf-8'))) updated = True if updated: self._sysctl_dir_apply_system(host) From e05ba723be5c83543d9075ac8e64dcd799bbdc4a Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 18 May 2026 16:17:47 +0530 Subject: [PATCH 171/596] cephadm: moved check-online,sysctl-dir subcommands under _orch Moved above mentioned commands under the existing cephadm _orch namespace Fixes: https://tracker.ceph.com/issues/74045 Signed-off-by: Shweta Bhosale --- doc/cephadm/host-management.rst | 3 +- doc/man/8/cephadm.rst | 39 ++++------------ src/cephadm/cephadm.py | 54 ++++++++++++----------- src/cephadm/tests/test_cephadm.py | 8 ++-- src/pybind/mgr/cephadm/module.py | 9 ++-- src/pybind/mgr/cephadm/offline_watcher.py | 14 ++++-- src/pybind/mgr/cephadm/tests/test_ssh.py | 2 +- src/pybind/mgr/cephadm/tuned_profiles.py | 6 +-- 8 files changed, 62 insertions(+), 73 deletions(-) diff --git a/doc/cephadm/host-management.rst b/doc/cephadm/host-management.rst index 7167ae31570..0fd4f1284e0 100644 --- a/doc/cephadm/host-management.rst +++ b/doc/cephadm/host-management.rst @@ -552,8 +552,7 @@ cephadm operations. Run a command of the following form: ceph cephadm set-user The ``set-user`` command automatically configures the specified user on all cluster -hosts by calling ``cephadm setup-ssh-user`` on each host. This command is available starting -with the Umbrella release and includes the following: +hosts by calling ``cephadm setup-ssh-user`` on each host. This command includes the following: - Setting up passwordless sudo access for non-root users - Authorizing the cluster's SSH public key for the user diff --git a/doc/man/8/cephadm.rst b/doc/man/8/cephadm.rst index c8f2ba98d29..5843448d19d 100644 --- a/doc/man/8/cephadm.rst +++ b/doc/man/8/cephadm.rst @@ -13,7 +13,7 @@ Synopsis | [--log-dir LOG_DIR] [--logrotate-dir LOGROTATE_DIR] | [--unit-dir UNIT_DIR] [--verbose] [--timeout TIMEOUT] | [--retry RETRY] [--no-container-init] -| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,deploy-file,sysctl-dir,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,check-online,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} +| {version,pull,inspect-image,ls,list-networks,list-rdma,adopt,rm-daemon,rm-cluster,remove-file,deploy-file,run,shell,enter,ceph-volume,unit,logs,bootstrap,deploy,check-host,prepare-host,prepare-host-sudo-hardening,setup-ssh-user,add-repo,rm-repo,install,list-images,update-osd-service} | ... @@ -90,17 +90,19 @@ Synopsis | **cephadm** **check-host** [-h] [--expect-hostname EXPECT_HOSTNAME] -| **cephadm** **check-online** - | **cephadm** **remove-file** [-h] [--fsid FSID] --path PATH | **cephadm** **deploy-file** [-h] [--fsid FSID] --path PATH [--mode MODE] | [--uid UID] [--gid GID] -| **cephadm** **sysctl-dir** [-h] [--fsid FSID] (--list | --apply-system) - | **cephadm** **prepare-host** +| **cephadm** **prepare-host-sudo-hardening** [-h] [--ssh-user SSH_USER] +| [--ssh-pub-key SSH_PUB_KEY] +| [--cephadm-version VERSION] + +| **cephadm** **setup-ssh-user** [-h] --ssh-user SSH_USER --ssh-pub-key SSH_PUB_KEY + | **cephadm** **add-repo** [-h] [--release RELEASE] [--version VERSION] | [--dev DEV] [--dev-commit DEV_COMMIT] | [--gpg-url GPG_URL] [--repo-url REPO_URL] @@ -298,25 +300,14 @@ Arguments: * [--expect-hostname EXPECT_HOSTNAME] Check that hostname matches an expected value -check-online ------------- - -check that the host is online by running ``true`` locally. - -This command is primarily intended for cephadm internals (for example, the -offline host watcher), rather than direct operator workflows. - - remove-file ----------- Remove a regular file on the local host. Missing paths are ignored. -Refuses directories and symbolic links, only plain files are -removed. Arguments: -* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) +* [--fsid FSID] cluster FSID * --path PATH absolute path of the file to remove (required) @@ -328,25 +319,13 @@ Write or replace a file on the local host. The **entire file body** is read from Arguments: -* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) +* [--fsid FSID] cluster FSID * --path PATH absolute destination path for the file (required) * [--mode MODE] octal file mode (for example ``644`` or ``0644``) * [--uid UID] numeric owner user id (**must** be given together with ``--gid``) * [--gid GID] numeric owner group id (**must** be given together with ``--uid``) -sysctl-dir ----------- - -List basenames under ``/etc/sysctl.d`` or run ``sysctl --system`` on the local host. - -Arguments: - -* [--fsid FSID] cluster FSID (passed automatically when invoked by the orchestrator) -* --list print one directory entry per line (sorted) -* --apply-system reload sysctl settings from all configuration paths - - deploy ------ diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 11de13858b5..1e788ceadf2 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -5663,6 +5663,32 @@ def _get_parser(): help='Configuration input source file', ) + parser_check_online = subparsers_orch.add_parser( + 'check-online', help='return true to indicate host is running') + parser_check_online.set_defaults(func=command_check_online) + + parser_sysctl_dir = subparsers_orch.add_parser( + 'sysctl-dir', + help='list entries in sysctl.d or run sysctl --system') + parser_sysctl_dir.set_defaults(func=command_sysctl_dir) + parser_sysctl_dir.add_argument( + '--fsid', + help='cluster FSID') + _sysctl_dir_action = parser_sysctl_dir.add_mutually_exclusive_group( + required=True) + _sysctl_dir_action.add_argument( + '--list', + dest='sysctl_dir_action', + action='store_const', + const='list', + help=f'print one basename per line from {SYSCTL_DIR}') + _sysctl_dir_action.add_argument( + '--apply-system', + dest='sysctl_dir_action', + action='store_const', + const='apply_system', + help='reload sysctl settings from all config paths (sysctl --system)') + parser_check_host = subparsers.add_parser( 'check-host', help='check host configuration') parser_check_host.set_defaults(func=command_check_host) @@ -5670,10 +5696,6 @@ def _get_parser(): '--expect-hostname', help='Check that hostname matches an expected value') - parser_check_online = subparsers.add_parser( - 'check-online', help='return true to indicate host is running') - parser_check_online.set_defaults(func=command_check_online) - parser_prepare_host = subparsers.add_parser( 'prepare-host', help='prepare a host for cephadm use') parser_prepare_host.set_defaults(func=command_prepare_host) @@ -5808,28 +5830,6 @@ def _get_parser(): default=None, help='numeric owner gid (requires --uid)') - parser_sysctl_dir = subparsers.add_parser( - 'sysctl-dir', - help='list entries in sysctl.d or run sysctl --system') - parser_sysctl_dir.set_defaults(func=command_sysctl_dir) - parser_sysctl_dir.add_argument( - '--fsid', - help='cluster FSID') - _sysctl_dir_action = parser_sysctl_dir.add_mutually_exclusive_group( - required=True) - _sysctl_dir_action.add_argument( - '--list', - dest='sysctl_dir_action', - action='store_const', - const='list', - help=f'print one basename per line from {SYSCTL_DIR}') - _sysctl_dir_action.add_argument( - '--apply-system', - dest='sysctl_dir_action', - action='store_const', - const='apply_system', - help='reload sysctl settings from all config paths (sysctl --system)') - parser_maintenance = subparsers.add_parser( 'host-maintenance', help='Manage the maintenance state of a host') parser_maintenance.add_argument( @@ -5975,6 +5975,8 @@ def main() -> None: command_check_host, command_check_online, command_prepare_host, + command_setup_ssh_user, + command_prepare_host_sudo_hardening, command_add_repo, command_rm_repo, command_install, diff --git a/src/cephadm/tests/test_cephadm.py b/src/cephadm/tests/test_cephadm.py index f57b224468c..3b9a70424a9 100644 --- a/src/cephadm/tests/test_cephadm.py +++ b/src/cephadm/tests/test_cephadm.py @@ -191,7 +191,7 @@ class TestCephAdm(object): cephadm_fs.create_file(os.path.join(SYSCTL_DIR, 'c.conf')) cephadm_fs.create_file(os.path.join(SYSCTL_DIR, 'a.conf')) with with_cephadm_ctx( - ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] + ['_orch', 'sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] ) as ctx: assert _cephadm.command_sysctl_dir(ctx) == 0 assert capsys.readouterr().out.splitlines() == ['a.conf', 'c.conf'] @@ -204,14 +204,14 @@ class TestCephAdm(object): shutil.rmtree(SYSCTL_DIR) assert not cephadm_fs.exists(SYSCTL_DIR) with with_cephadm_ctx( - ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] + ['_orch', 'sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--list'] ) as ctx: with pytest.raises(_cephadm.Error, match='Not a directory'): _cephadm.command_sysctl_dir(ctx) def test_command_sysctl_dir_apply_system(self, cephadm_fs): with with_cephadm_ctx( - ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'] + ['_orch', 'sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'] ) as ctx: assert _cephadm.command_sysctl_dir(ctx) == 0 @@ -219,7 +219,7 @@ class TestCephAdm(object): # Do not let with_cephadm_ctx re-patch cephadm.call back to success (exit 0). with mock.patch('cephadm.call', return_value=('out', 'sysctl failed', 1)): with with_cephadm_ctx( - ['sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'], + ['_orch', 'sysctl-dir', '--fsid', '00000000-0000-0000-0000-0000deadbeef', '--apply-system'], mock_cephadm_call_fn=False, ) as ctx: with pytest.raises(_cephadm.Error, match='sysctl --system failed'): diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index c81ee9b9c3f..d201da57a0a 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -1197,7 +1197,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): def _setup_user_on_host(self, host: str, user: str, ssh_pub_key: str, addr: Optional[str] = None) -> None: """ - Setup sudoers and copy SSH key by calling cephadm setup-ssh-user command. + Setup sudoers and copy SSH key by calling cephadm setup-ssh-user. User must already exist on the host. For root user, only SSH key is copied (sudoers setup is skipped). """ @@ -1582,7 +1582,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): addr: Optional[str] = None ) -> Tuple[str, bool, str]: """ - Prepare a host for sudo hardening by executing 'cephadm prepare-host-sudo-hardening' command. + Prepare a host for sudo hardening by executing cephadm prepare-host-sudo-hardening. """ try: self.log.debug('Preparing host %s for sudo hardening...', host) @@ -1591,7 +1591,10 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): with self.async_timeout_handler(host, 'cephadm prepare-host-sudo-hardening'): out, err, code = self.wait_async( CephadmServe(self)._run_cephadm( - host, cephadmNoImage, 'prepare-host-sudo-hardening', cephadm_args, + host, + cephadmNoImage, + 'prepare-host-sudo-hardening', + cephadm_args, addr=addr, error_ok=True, no_fsid=True)) if code: error_msg = '\n'.join(err) if err else 'Unknown error' diff --git a/src/pybind/mgr/cephadm/offline_watcher.py b/src/pybind/mgr/cephadm/offline_watcher.py index c8bfa4cb3d1..b96475f9a98 100644 --- a/src/pybind/mgr/cephadm/offline_watcher.py +++ b/src/pybind/mgr/cephadm/offline_watcher.py @@ -41,10 +41,16 @@ class OfflineHostWatcher(threading.Thread): def check_host(self, host: str) -> None: if host not in self.mgr.offline_hosts: try: - with self.mgr.async_timeout_handler(host, 'cephadm check-online'): - self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( - host, cephadmNoImage, 'check-online', [], - no_fsid=True, log_output=self.mgr.log_refresh_metadata)) + with self.mgr.async_timeout_handler( + host, 'cephadm _orch check-online'): + self.mgr.wait_async( + CephadmServe(self.mgr)._run_cephadm( + host, + cephadmNoImage, + ['_orch', 'check-online'], + [], + no_fsid=True, + log_output=self.mgr.log_refresh_metadata)) except Exception: logger.debug(f'OfflineHostDetector: detected {host} to be offline') # kick serve loop in case corrective action must be taken for offline host diff --git a/src/pybind/mgr/cephadm/tests/test_ssh.py b/src/pybind/mgr/cephadm/tests/test_ssh.py index ebb80b18b94..5a8b380a4b6 100644 --- a/src/pybind/mgr/cephadm/tests/test_ssh.py +++ b/src/pybind/mgr/cephadm/tests/test_ssh.py @@ -123,6 +123,6 @@ def test_offline_watcher_uses_cephadm_check_online(run_cephadm, cephadm_module): cephadm_module.offline_watcher.check_host('test') run_cephadm.assert_called_once_with( - 'test', cephadmNoImage, 'check-online', [], + 'test', cephadmNoImage, ['_orch', 'check-online'], [], no_fsid=True, log_output=cephadm_module.log_refresh_metadata ) diff --git a/src/pybind/mgr/cephadm/tuned_profiles.py b/src/pybind/mgr/cephadm/tuned_profiles.py index 9223b1e0842..941494d299f 100644 --- a/src/pybind/mgr/cephadm/tuned_profiles.py +++ b/src/pybind/mgr/cephadm/tuned_profiles.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) SYSCTL_DIR = '/etc/sysctl.d' -SYSCTL_DIR_CEPHADM_CMD = 'sysctl-dir' +SYSCTL_DIR_CEPHADM_CMD = ['_orch', 'sysctl-dir'] class TunedProfileUtils(): @@ -21,7 +21,7 @@ class TunedProfileUtils(): self.mgr = mgr def _sysctl_dir_list(self, host: str) -> str: - with self.mgr.async_timeout_handler(host, 'cephadm sysctl-dir --list'): + with self.mgr.async_timeout_handler(host, 'cephadm _orch sysctl-dir --list'): out, _err, _code = self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( host, cephadmNoImage, @@ -32,7 +32,7 @@ class TunedProfileUtils(): return ''.join(out) def _sysctl_dir_apply_system(self, host: str) -> None: - with self.mgr.async_timeout_handler(host, 'cephadm sysctl-dir --apply-system'): + with self.mgr.async_timeout_handler(host, 'cephadm _orch sysctl-dir --apply-system'): self.mgr.wait_async(CephadmServe(self.mgr)._run_cephadm( host, cephadmNoImage, From b571051663fc4a0160aac618f8113b8664aeba79 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 11 Jun 2026 11:29:05 +0800 Subject: [PATCH 172/596] ceph.spec.in: use %{_sbindir} instead of ${_sbindir} in osd %preun a37b5b5bde8c added %preun scriptlets that use ${_sbindir}, which is shell syntax rather than an rpm macro, so it expands to empty at run time and the scriptlet runs "/update-alternatives", failing on uninstall/upgrade with: /var/tmp/rpm-tmp.K1fvm3: line 2: /update-alternatives: No such file or directory error: %preun(ceph-osd-crimson-2:20.3.0-5054.g33c1d671.el9.x86_64) scriptlet failed, exit status 127 Error in PREUN scriptlet in rpm package ceph-osd-crimson. use %{_sbindir}, like the %posttrans --install lines already do, so it expands to /usr/sbin/update-alternatives at build time. Fixes: https://tracker.ceph.com/issues/77323 Signed-off-by: Kefu Chai --- ceph.spec.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index 30028a2c7cd..9876fe5813b 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -2750,7 +2750,7 @@ fi %preun osd-crimson if [ $1 -eq 0 ]; then - ${_sbindir}/update-alternatives --remove ceph-osd %{_bindir}/ceph-osd-crimson + %{_sbindir}/update-alternatives --remove ceph-osd %{_bindir}/ceph-osd-crimson fi %endif @@ -2760,7 +2760,7 @@ fi %preun osd-classic if [ $1 -eq 0 ]; then - ${_sbindir}/update-alternatives --remove ceph-osd %{_bindir}/ceph-osd-classic + %{_sbindir}/update-alternatives --remove ceph-osd %{_bindir}/ceph-osd-classic fi %files volume From 7fc3a9e12f7539eabcaf80d3960a588d81983710 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 11 Jun 2026 11:32:24 +0800 Subject: [PATCH 173/596] ceph.spec.in: require update-alternatives for the osd scriptlets ceph-osd-crimson and ceph-osd-classic call update-alternatives in their %posttrans and %preun scriptlets but don't depend on it. declare it as a scriptlet dependency so the binary is there when they run. Fixes: https://tracker.ceph.com/issues/77323 Signed-off-by: Kefu Chai --- ceph.spec.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ceph.spec.in b/ceph.spec.in index 9876fe5813b..4564703a38a 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -1255,6 +1255,8 @@ Group: System/Filesystems %endif Requires: ceph-osd = %{_epoch_prefix}%{version}-%{release} Obsoletes: ceph-osd < %{_epoch_prefix}%{version}-%{release} +Requires(posttrans): %{_sbindir}/update-alternatives +Requires(preun): %{_sbindir}/update-alternatives %description osd-classic classic-osd is the object storage daemon for the Ceph distributed file system. It is responsible for storing objects on a local file system @@ -1275,6 +1277,8 @@ Requires: binutils Requires: c-ares%{?_isa} >= %{c_ares_min_version} %endif Requires: protobuf +Requires(posttrans): %{_sbindir}/update-alternatives +Requires(preun): %{_sbindir}/update-alternatives %description osd-crimson crimson-osd is the object storage daemon for the Ceph distributed file system. It is responsible for storing objects on a local file system From 8ef8a1e2f378f13afc695be74590d3ffc3454dcb Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 23 Oct 2025 20:36:06 +0530 Subject: [PATCH 174/596] mgr/cephadm: Signal handling while daemon reconfig Fixes: https://tracker.ceph.com/issues/73633 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 25 +++++++++++++++++++++---- src/pybind/mgr/cephadm/module.py | 8 ++++++-- src/pybind/mgr/cephadm/serve.py | 12 +++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 7cf65220c80..201b0319ba9 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -1202,10 +1202,15 @@ def deploy_daemon( # If this was a reconfig and the daemon is not a Ceph daemon, restart it # so it can pick up potential changes to its configuration files if deployment_type == DeploymentType.RECONFIG and daemon_type not in ceph_daemons(): - # ceph daemons do not need a restart; others (presumably) do to pick - # up the new config - call_throws(ctx, ['systemctl', 'reset-failed', ident.unit_name]) - call_throws(ctx, ['systemctl', 'restart', ident.unit_name]) + if not ctx.skip_restart_for_reconfig: + # ceph daemons do not need a restart; others (presumably) do to pick + # up the new config + call_throws(ctx, ['systemctl', 'reset-failed', ident.unit_name]) + call_throws(ctx, ['systemctl', 'restart', ident.unit_name]) + elif ctx.send_signal_to_daemon: + ctx.signal_name = ctx.send_signal_to_daemon + ctx.signal_number = None + command_signal(ctx) def clean_cgroup(ctx: CephadmContext, fsid: str, unit_name: str) -> None: @@ -4896,6 +4901,18 @@ def _add_deploy_parser_args( default=None, help='Time in seconds to wait for graceful service shutdown before forcefully killing it' ) + parser_deploy.add_argument( + '--skip-restart-for-reconfig', + action='store_true', + default=False, + help='skip restart for non ceph daemons and perform default action' + ) + parser_deploy.add_argument( + '--send-signal-to-daemon', + action='store_true', + default=False, + help='Send signal to daemon' + ) def _name_opts(parser: argparse.ArgumentParser) -> None: diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index ef4699838d1..bad6568f500 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -2710,7 +2710,9 @@ Then run the following: def _daemon_action(self, daemon_spec: CephadmDaemonDeploySpec, action: str, - image: Optional[str] = None) -> str: + image: Optional[str] = None, + skip_restart_for_reconfig: bool = False, + send_signal_to_daemon: Optional[str] = None) -> str: self._daemon_action_set_image(action, image, daemon_spec.daemon_type, daemon_spec.daemon_id) @@ -2735,7 +2737,9 @@ Then run the following: daemon_spec) with self.async_timeout_handler(daemon_spec.host, f'cephadm deploy ({daemon_spec.daemon_type} daemon)'): return self.wait_async( - CephadmServe(self)._create_daemon(daemon_spec, reconfig=(action == 'reconfig'))) + CephadmServe(self)._create_daemon(daemon_spec, reconfig=(action == 'reconfig'), + skip_restart_for_reconfig=skip_restart_for_reconfig, + send_signal_to_daemon=send_signal_to_daemon)) actions = { 'start': ['reset-failed', 'start'], diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 1f1b5c2fd6f..4f5d37250e2 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1182,6 +1182,8 @@ class CephadmServe: dd.hostname, dd.name() ) ) + skip_restart_for_reconfig = False + send_signal_to_daemon = None if not last_config: self.log.info('Reconfiguring %s (unknown last config time)...' % ( dd.name())) @@ -1231,7 +1233,9 @@ class CephadmServe: action = 'redeploy' try: daemon_spec = CephadmDaemonDeploySpec.from_daemon_description(dd) - self.mgr._daemon_action(daemon_spec, action=action) + self.mgr._daemon_action(daemon_spec, action=action, + skip_restart_for_reconfig=skip_restart_for_reconfig, + send_signal_to_daemon=send_signal_to_daemon) if self.mgr.cache.rm_scheduled_daemon_action(dd.hostname, dd.name()): self.mgr.cache.save_host(dd.hostname) except OrchestratorError as e: @@ -1411,6 +1415,8 @@ class CephadmServe: daemon_spec: CephadmDaemonDeploySpec, reconfig: bool = False, osd_uuid_map: Optional[Dict[str, Any]] = None, + skip_restart_for_reconfig: bool = False, + send_signal_to_daemon: Optional[str] = None, ) -> str: daemon_params: Dict[str, Any] = {} @@ -1451,6 +1457,10 @@ class CephadmServe: if reconfig: daemon_params['reconfig'] = True + if skip_restart_for_reconfig: + daemon_params['skip_restart_for_reconfig'] = True + if send_signal_to_daemon: + daemon_params['send_signal_to_daemon'] = send_signal_to_daemon if self.mgr.allow_ptrace: daemon_params['allow_ptrace'] = True From efeb0fe52f921abaa6d60cb273674ec42819665d Mon Sep 17 00:00:00 2001 From: ShreeJejurikar Date: Wed, 3 Jun 2026 16:59:33 +0530 Subject: [PATCH 175/596] rgw: emit bucket logging records for lifecycle delete actions LC delete actions now emit LIFECYCLE.DELETE.OBJECT journal records via no-req_state overloads of the bucketlogging frontend. Signed-off-by: ShreeJejurikar --- src/rgw/radosgw-admin/radosgw-admin.cc | 2 +- src/rgw/rgw_bucket_logging.cc | 257 ++++++++++----- src/rgw/rgw_bucket_logging.h | 44 ++- src/rgw/rgw_bucket_logging_types.h | 59 ++++ src/rgw/rgw_lc.cc | 61 +++- src/rgw/rgw_rest_bucket_logging.cc | 4 +- .../rgw/bucket_logging/test_bucket_logging.py | 297 +++++++++++++++++- 7 files changed, 638 insertions(+), 86 deletions(-) create mode 100644 src/rgw/rgw_bucket_logging_types.h diff --git a/src/rgw/radosgw-admin/radosgw-admin.cc b/src/rgw/radosgw-admin/radosgw-admin.cc index 2f2cbe9dc78..183b202be3a 100644 --- a/src/rgw/radosgw-admin/radosgw-admin.cc +++ b/src/rgw/radosgw-admin/radosgw-admin.cc @@ -7966,7 +7966,7 @@ int main(int argc, const char **argv) } std::string old_obj; const auto region = driver->get_zone()->get_zonegroup().get_api_name(); - ret = rgw::bucketlogging::rollover_logging_object(configuration, target_bucket, obj_name, dpp(), region, bucket, null_yield, true, &objv_tracker, false, &old_obj); + ret = rgw::bucketlogging::rollover_logging_object(configuration, target_bucket, obj_name, dpp(), region, bucket.get(), null_yield, true, &objv_tracker, false, &old_obj); if (ret < 0) { cerr << "ERROR: failed to flush pending logging object '" << obj_name << "' to target bucket '" << configuration.target_bucket << "'. error: " << cpp_strerror(-ret) << std::endl; diff --git a/src/rgw/rgw_bucket_logging.cc b/src/rgw/rgw_bucket_logging.cc index c7f46605876..3ca820ee214 100644 --- a/src/rgw/rgw_bucket_logging.cc +++ b/src/rgw/rgw_bucket_logging.cc @@ -5,6 +5,7 @@ #include #include "common/ceph_time.h" #include "rgw_bucket_logging.h" +#include "rgw_bucket_logging_types.h" #include "rgw_xml.h" #include "rgw_sal.h" #include "rgw_op.h" @@ -239,7 +240,7 @@ ceph::coarse_real_time time_from_name(const std::string& obj_name, const DoutPre return extracted_time; } -std::string full_bucket_name(const std::unique_ptr& bucket) { +std::string full_bucket_name(const rgw::sal::Bucket* bucket) { if (bucket->get_tenant().empty()) { return bucket->get_name(); } @@ -251,7 +252,7 @@ int new_logging_object(const configuration& conf, std::string& obj_name, const DoutPrefixProvider *dpp, const std::string& region, - const std::unique_ptr& source_bucket, + rgw::sal::Bucket* source_bucket, optional_yield y, std::optional old_name, RGWObjVersionTracker* objv_tracker) { @@ -347,7 +348,7 @@ int rollover_logging_object(const configuration& conf, std::string& obj_name, const DoutPrefixProvider *dpp, const std::string& region, - const std::unique_ptr& source_bucket, + rgw::sal::Bucket* source_bucket, optional_yield y, bool must_commit, RGWObjVersionTracker* objv_tracker, @@ -465,16 +466,73 @@ int log_record(rgw::sal::Driver* driver, optional_yield y, bool async_completion, bool log_source_bucket) { - if (!s->bucket) { - ldpp_dout(dpp, 1) << "ERROR: only bucket operations are logged in bucket logging" << dendl; - return -EINVAL; // this should never happen + record_input input; + input.bucket = s->bucket.get(); + input.time = s->time; + if (obj) { + input.version_id = obj->get_instance(); } - auto set_journal_err = [&conf, s](const std::string& err_message) { - if (conf.logging_type == LoggingType::Journal) s->err.message = err_message; + input.journal_err_out = &s->err.message; + // AWS S3 spec: log the assumed-role ARN for STS-credentialed requests. + if (s->auth.identity && s->auth.identity->get_identity_type() == TYPE_ROLE) { + if (auto caller_arn = s->auth.identity->get_caller_identity(); caller_arn) { + input.user_or_account = caller_arn->to_string(); + } + } + if (input.user_or_account.empty()) { + if (s->account_name.empty()) { + s->user->get_id().to_str(input.user_or_account); + } else { + input.user_or_account = s->account_name; + } + } + input.fqdn = s->info.host; + if (!s->info.domain.empty() && !input.fqdn.empty()) { + input.fqdn.append(".").append(s->info.domain); + } + rgw::auth::s3::get_aws_version_and_auth_type(s, input.aws_version, input.auth_type); + input.time_elapsed = std::chrono::duration_cast(s->time_elapsed()); + input.remote_addr = s->info.env->get("REMOTE_ADDR", "-"); + input.trans_id = s->trans_id; + input.method = s->info.method; + input.request_uri = s->info.request_uri; + input.request_params = s->info.request_params; + input.referrer = s->info.env->get("HTTP_REFERER", "-"); + input.user_agent = s->info.env->get("HTTP_USER_AGENT", "-"); + input.ssl_cipher = s->info.env->get("SSL_CIPHER", "-"); + input.tls_version = s->info.env->get("TLS_VERSION", "-"); + input.x_amz_id_2 = s->info.x_meta_map.contains("x-amz-id-2") ? + s->info.x_meta_map.at("x-amz-id-2") : "-"; + input.http_ret = s->err.http_ret; + input.err_code = s->err.err_code; + input.content_length = s->content_length; + input.granted_by_acl = s->granted_by_acl; + input.src_object = s->src_object.get(); + input.src_bucket_name = s->src_bucket_name; + return log_record(driver, obj, input, op_name, etag, size, conf, + dpp, y, async_completion, log_source_bucket); +} + +int log_record(rgw::sal::Driver* driver, + const sal::Object* obj, + const record_input& input, + const std::string& op_name, + const std::string& etag, + size_t size, + const configuration& conf, + const DoutPrefixProvider *dpp, + optional_yield y, + bool async_completion, + bool log_source_bucket) { + auto set_journal_err = [&conf, &input](const std::string& err_message) { + if (conf.logging_type == LoggingType::Journal && input.journal_err_out) { + *input.journal_err_out = err_message; + } }; std::string target_bucket_name; std::string target_tenant_name; - int ret = rgw_parse_url_bucket(conf.target_bucket, s->bucket_tenant, target_tenant_name, target_bucket_name); + int ret = rgw_parse_url_bucket(conf.target_bucket, input.bucket->get_tenant(), + target_tenant_name, target_bucket_name); if (ret < 0) { ldpp_dout(dpp, 1) << "ERROR: failed to parse logging bucket name '" << conf.target_bucket << "', ret = " << ret << dendl; set_journal_err(fmt::format("Failed to parse logging bucket name '{}'", conf.target_bucket)); @@ -492,7 +550,11 @@ int log_record(rgw::sal::Driver* driver, rgw::ARN target_resource_arn(target_bucket_id, conf.target_prefix); std::string err_message; - if (ret = verify_target_bucket_policy(dpp, target_bucket.get(), target_resource_arn, s, &err_message); ret < 0) { + if (ret = verify_target_bucket_policy(dpp, target_bucket.get(), target_resource_arn, + dpp->get_cct(), + rgw::ARN(input.bucket->get_key()).to_string(), + to_string(input.bucket->get_owner()), + &err_message); ret < 0) { set_journal_err(err_message); return ret; } @@ -503,9 +565,9 @@ int log_record(rgw::sal::Driver* driver, } // make sure that the logging source attribute is up-to-date - if (ret = update_bucket_logging_sources(dpp, target_bucket, s->bucket->get_key(), true, y); ret < 0) { + if (ret = update_bucket_logging_sources(dpp, target_bucket, input.bucket->get_key(), true, y); ret < 0) { ldpp_dout(dpp, 5) << "WARNING: could not update bucket logging source '" << - s->bucket->get_key() << "' in logging bucket '" << target_bucket_id << "' attribute, during record logging. ret = " << ret << dendl; + input.bucket->get_key() << "' in logging bucket '" << target_bucket_id << "' attribute, during record logging. ret = " << ret << dendl; } const auto region = driver->get_zone()->get_zonegroup().get_api_name(); @@ -517,7 +579,7 @@ int log_record(rgw::sal::Driver* driver, if (ceph::coarse_real_time::clock::now() > time_to_commit) { ldpp_dout(dpp, 20) << "INFO: logging object '" << obj_name << "' exceeded its time, will be committed to logging bucket '" << target_bucket_id << "'" << dendl; - if (ret = rollover_logging_object(conf, target_bucket, obj_name, dpp, region, s->bucket, y, false, &objv_tracker, true, nullptr, &err_message); ret < 0 && ret != -ECANCELED) { + if (ret = rollover_logging_object(conf, target_bucket, obj_name, dpp, region, input.bucket, y, false, &objv_tracker, true, nullptr, &err_message); ret < 0 && ret != -ECANCELED) { set_journal_err(err_message); return ret; } @@ -526,7 +588,7 @@ int log_record(rgw::sal::Driver* driver, } } else if (ret == -ENOENT) { // try to create the temporary log object for the first time - ret = new_logging_object(conf, target_bucket, obj_name, dpp, region, s->bucket, y, std::nullopt, &objv_tracker); + ret = new_logging_object(conf, target_bucket, obj_name, dpp, region, input.bucket, y, std::nullopt, &objv_tracker); if (ret == 0) { ldpp_dout(dpp, 20) << "INFO: first time logging for logging bucket '" << target_bucket_id << "' and prefix '" << conf.target_prefix << "'" << dendl; @@ -547,44 +609,23 @@ int log_record(rgw::sal::Driver* driver, } std::string record; - const auto tt = ceph::coarse_real_time::clock::to_time_t(s->time); + const auto tt = ceph::coarse_real_time::clock::to_time_t(input.time); std::tm t{}; localtime_r(&tt, &t); - // AWS S3 spec: log the assumed-role ARN for STS-credentialed requests. - std::string user_or_account; - if (s->auth.identity && s->auth.identity->get_identity_type() == TYPE_ROLE) { - if (auto caller_arn = s->auth.identity->get_caller_identity(); caller_arn) { - user_or_account = caller_arn->to_string(); - } - } - if (user_or_account.empty()) { - if (s->account_name.empty()) { - s->user->get_id().to_str(user_or_account); - } else { - user_or_account = s->account_name; - } - } - auto fqdn = s->info.host; - if (!s->info.domain.empty() && !fqdn.empty()) { - fqdn.append(".").append(s->info.domain); - } - std::string aws_version("-"); - std::string auth_type("-"); - rgw::auth::s3::get_aws_version_and_auth_type(s, aws_version, auth_type); std::string bucket_owner; std::string bucket_name; if (log_source_bucket && conf.logging_type == LoggingType::Standard) { // log source bucket for COPY operations only in standard mode - if (!s->src_object || !s->src_object->get_bucket()) { + if (!input.src_object || !input.src_object->get_bucket()) { ldpp_dout(dpp, 1) << "ERROR: source object or bucket is missing when logging source bucket" << dendl; return -EINVAL; } - bucket_owner = to_string(s->src_object->get_bucket()->get_owner()); - bucket_name = s->src_bucket_name; + bucket_owner = to_string(input.src_object->get_bucket()->get_owner()); + bucket_name = input.src_bucket_name; } else { - bucket_owner = to_string(s->bucket->get_owner()); - bucket_name = full_bucket_name(s->bucket); + bucket_owner = to_string(input.bucket->get_owner()); + bucket_name = full_bucket_name(input.bucket); } switch (conf.logging_type) { @@ -593,32 +634,32 @@ int log_record(rgw::sal::Driver* driver, dash_if_empty(bucket_owner), dash_if_empty(bucket_name), t, - s->info.env->get("REMOTE_ADDR", "-"), - dash_if_empty(user_or_account), - dash_if_empty(s->trans_id), + dash_if_empty(input.remote_addr), + dash_if_empty(input.user_or_account), + dash_if_empty(input.trans_id), op_name, dash_if_empty_or_null(obj, obj->get_name()), - s->info.method, - s->info.request_uri, - s->info.request_params.empty() ? "" : "?", - s->info.request_params, - dash_if_zero(s->err.http_ret), - dash_if_empty(s->err.err_code), - dash_if_zero(s->content_length), + input.method, + input.request_uri, + input.request_params.empty() ? "" : "?", + input.request_params, + dash_if_zero(input.http_ret), + dash_if_empty(input.err_code), + dash_if_zero(input.content_length), dash_if_zero(size), "-", // no total time when logging record - std::chrono::duration_cast(s->time_elapsed()), - s->info.env->get("HTTP_REFERER", "-"), - s->info.env->get("HTTP_USER_AGENT", "-"), - dash_if_empty_or_null(obj, obj->get_instance()), - s->info.x_meta_map.contains("x-amz-id-2") ? s->info.x_meta_map.at("x-amz-id-2") : "-", - aws_version, - s->info.env->get("SSL_CIPHER", "-"), - dash_if_empty(auth_type), - dash_if_empty(fqdn), - s->info.env->get("TLS_VERSION", "-"), + input.time_elapsed, + dash_if_empty(input.referrer), + dash_if_empty(input.user_agent), + dash_if_empty(input.version_id), + dash_if_empty(input.x_amz_id_2), + dash_if_empty(input.aws_version), + dash_if_empty(input.ssl_cipher), + dash_if_empty(input.auth_type), + dash_if_empty(input.fqdn), + dash_if_empty(input.tls_version), "-", // no access point ARN - (s->granted_by_acl) ? "Yes" : "-"); + (input.granted_by_acl) ? "Yes" : "-"); break; case LoggingType::Journal: record = fmt::format("{} {} [{:%d/%b/%Y:%H:%M:%S %z}] {} {} {} {} {}", @@ -628,7 +669,7 @@ int log_record(rgw::sal::Driver* driver, op_name, dash_if_empty_or_null(obj, obj->get_name()), dash_if_zero(size), - dash_if_empty_or_null(obj, obj->get_instance()), + dash_if_empty(input.version_id), dash_if_empty(etag)); break; case LoggingType::Any: @@ -679,7 +720,7 @@ int log_record(rgw::sal::Driver* driver, if (ret == -EFBIG) { ldpp_dout(dpp, 5) << "WARNING: logging object '" << obj_name << "' is full, will be committed to logging bucket '" << target_bucket->get_key() << "'" << dendl; - if (ret = rollover_logging_object(conf, target_bucket, obj_name, dpp, region, s->bucket, y, true, &objv_tracker, true, nullptr, &err_message); ret < 0 && ret != -ECANCELED) { + if (ret = rollover_logging_object(conf, target_bucket, obj_name, dpp, region, input.bucket, y, true, &objv_tracker, true, nullptr, &err_message); ret < 0 && ret != -ECANCELED) { set_journal_err(err_message); return ret; } @@ -736,7 +777,9 @@ int log_record(rgw::sal::Driver* driver, return 0; } if (configuration.key_filter.has_content()) { - if (!match(configuration.key_filter, obj->get_name())) { + // No-op (rather than crash) for bucket-level ops with no object: can't + // tell if a missing key matches the filter. + if (!obj || !match(configuration.key_filter, obj->get_name())) { return 0; } } @@ -747,6 +790,57 @@ int log_record(rgw::sal::Driver* driver, "'. ret=" << ret << dendl; return ret; } + } catch (buffer::error& err) { + ldpp_dout(dpp, 1) << "ERROR: failed to decode logging attribute '" << RGW_ATTR_BUCKET_LOGGING + << "'. error: " << err.what() << dendl; + return -EINVAL; + } + return 0; +} + +int log_record(rgw::sal::Driver* driver, + LoggingType type, + const sal::Object* obj, + const record_input& input, + const std::string& op_name, + const std::string& etag, + size_t size, + const DoutPrefixProvider *dpp, + optional_yield y, + bool async_completion, + bool log_source_bucket) { + if (!input.bucket) { + ldpp_dout(dpp, 1) << "ERROR: only bucket operations are logged in bucket logging" << dendl; + return -EINVAL; + } + // check if bucket logging is needed + const auto& bucket_attrs = input.bucket->get_attrs(); + auto iter = bucket_attrs.find(RGW_ATTR_BUCKET_LOGGING); + if (iter == bucket_attrs.end()) { + return 0; + } + configuration configuration; + try { + configuration.enabled = true; + auto bl_iter = iter->second.cbegin(); + decode(configuration, bl_iter); + if (type != LoggingType::Any && configuration.logging_type != type) { + return 0; + } + if (configuration.key_filter.has_content()) { + // No-op (rather than crash) for bucket-level ops with no object: can't + // tell if a missing key matches the filter. + if (!obj || !match(configuration.key_filter, obj->get_name())) { + return 0; + } + } + ldpp_dout(dpp, 20) << "INFO: found matching logging configuration of bucket '" << input.bucket->get_key() << + "' configuration: " << configuration.to_json_str() << dendl; + if (const int ret = log_record(driver, obj, input, op_name, etag, size, configuration, dpp, y, async_completion, log_source_bucket); ret < 0) { + ldpp_dout(dpp, 1) << "ERROR: failed to perform logging for bucket '" << input.bucket->get_key() << + "'. ret=" << ret << dendl; + return ret; + } } catch (buffer::error& err) { ldpp_dout(dpp, 1) << "ERROR: failed to decode logging attribute '" << RGW_ATTR_BUCKET_LOGGING << "'. error: " << err.what() << dendl; @@ -977,7 +1071,9 @@ int source_bucket_cleanup(const DoutPrefixProvider* dpp, int verify_target_bucket_policy(const DoutPrefixProvider* dpp, rgw::sal::Bucket* target_bucket, const rgw::ARN& target_resource_arn, - req_state* s, + CephContext* cct, + std::string source_bucket_arn, + std::string source_account, std::string* err_message) { // verify target permissions for bucket logging // this is implementing the policy based permission granting from: @@ -993,19 +1089,20 @@ int verify_target_bucket_policy(const DoutPrefixProvider* dpp, return -EACCES; } try { - const rgw::IAM::Policy policy{s->cct, &target_bucket_id.tenant, policy_it->second.to_str(), false}; + const rgw::IAM::Policy policy{cct, &target_bucket_id.tenant, policy_it->second.to_str(), false}; ldpp_dout(dpp, 20) << "INFO: logging bucket '" << target_bucket_id << "' policy: " << policy << dendl; rgw::auth::ServiceIdentity ident(rgw::bucketlogging::service_principal); - const auto source_bucket_arn = rgw::ARN(s->bucket->get_key()).to_string(); - const auto source_account = to_string(s->bucket_owner.id); - s->env.emplace("aws:SourceArn", source_bucket_arn); - s->env.emplace("aws:SourceAccount", source_account); - if (policy.eval(s->env, ident, rgw::IAM::s3PutObject, target_resource_arn) != rgw::IAM::Effect::Allow) { + // The AWS-spec policy for the logging service principal references only + // aws:SourceArn and aws:SourceAccount. Build a minimal env to match. + rgw::IAM::Environment env; + const auto arn_it = env.emplace("aws:SourceArn", std::move(source_bucket_arn)); + const auto acct_it = env.emplace("aws:SourceAccount", std::move(source_account)); + if (policy.eval(env, ident, rgw::IAM::s3PutObject, target_resource_arn) != rgw::IAM::Effect::Allow) { ldpp_dout(dpp, 1) << "ERROR: logging bucket: '" << target_bucket_id << "' must have a bucket policy that allows logging service principal to put objects in the following resource ARN: '" << - target_resource_arn.to_string() << "' from source bucket ARN: '" << source_bucket_arn << - "' and source account: '" << source_account << "'" << dendl; + target_resource_arn.to_string() << "' from source bucket ARN: '" << arn_it->second << + "' and source account: '" << acct_it->second << "'" << dendl; if (err_message) { *err_message = fmt::format("Logging bucket '{}' policy does not allow logging", target_bucket->get_name()); } @@ -1022,6 +1119,18 @@ int verify_target_bucket_policy(const DoutPrefixProvider* dpp, return 0; } +int verify_target_bucket_policy(const DoutPrefixProvider* dpp, + rgw::sal::Bucket* target_bucket, + const rgw::ARN& target_resource_arn, + req_state* s, + std::string* err_message) { + return verify_target_bucket_policy(dpp, target_bucket, target_resource_arn, + s->cct, + rgw::ARN(s->bucket->get_key()).to_string(), + to_string(s->bucket->get_owner()), + err_message); +} + int verify_target_bucket_attributes(const DoutPrefixProvider* dpp, rgw::sal::Bucket* target_bucket, std::string* err_message) { const auto& target_info = target_bucket->get_info(); if (target_info.requester_pays) { diff --git a/src/rgw/rgw_bucket_logging.h b/src/rgw/rgw_bucket_logging.h index 6d5b70bc9c9..344d6ddcd9f 100644 --- a/src/rgw/rgw_bucket_logging.h +++ b/src/rgw/rgw_bucket_logging.h @@ -158,6 +158,11 @@ inline std::string to_string(const Records& records) { return str_records; } +// Per-record input metadata for log_record(). Full definition lives in +// rgw_bucket_logging_types.h; callers that only pass it by reference need +// just this forward declaration. +struct record_input; + // log a bucket logging record according to the configuration int log_record(rgw::sal::Driver* driver, const sal::Object* obj, @@ -171,6 +176,19 @@ int log_record(rgw::sal::Driver* driver, bool async_completion, bool log_source_bucket); +// no-req_state variant of the log_record(req_state*, ..., configuration&) overload above +int log_record(rgw::sal::Driver* driver, + const sal::Object* obj, + const record_input& input, + const std::string& op_name, + const std::string& etag, + size_t size, + const configuration& conf, + const DoutPrefixProvider *dpp, + optional_yield y, + bool async_completion, + bool log_source_bucket); + // commit the pending log objec to the log bucket // and create a new pending log object // if "must_commit" is "false" the function will return success even if the pending log object was not committed @@ -181,7 +199,7 @@ int rollover_logging_object(const configuration& conf, std::string& obj_name, const DoutPrefixProvider *dpp, const std::string& region, - const std::unique_ptr& source_bucket, + rgw::sal::Bucket* source_bucket, optional_yield y, bool must_commit, RGWObjVersionTracker* objv_tracker, @@ -209,6 +227,19 @@ int log_record(rgw::sal::Driver* driver, bool async_completion, bool log_source_bucket); +// no-req_state variant of the log_record(LoggingType, ..., req_state*) overload above +int log_record(rgw::sal::Driver* driver, + LoggingType type, + const sal::Object* obj, + const record_input& input, + const std::string& op_name, + const std::string& etag, + size_t size, + const DoutPrefixProvider *dpp, + optional_yield y, + bool async_completion, + bool log_source_bucket); + // return (by ref) an rgw_bucket object with the bucket name and tenant name // fails if the bucket name is not in the format: [tenant name:] int get_bucket_id(const std::string& bucket_name, const std::string& tenant_name, rgw_bucket& bucket_id); @@ -252,6 +283,17 @@ int verify_target_bucket_policy(const DoutPrefixProvider* dpp, req_state* s, std::string* err_message = nullptr); +// no-req_state variant of the verify_target_bucket_policy(req_state*) overload above. +// source_bucket_arn and source_account are the ARN of the source bucket +// and the id of its owner; they are used as aws:SourceArn / aws:SourceAccount when evaluating the target (log) bucket's policy +int verify_target_bucket_policy(const DoutPrefixProvider* dpp, + rgw::sal::Bucket* target_bucket, + const rgw::ARN& target_resource_arn, + CephContext* cct, + std::string source_bucket_arn, + std::string source_account, + std::string* err_message = nullptr); + // verify that target bucket does not have: // - bucket logging // - requester pays diff --git a/src/rgw/rgw_bucket_logging_types.h b/src/rgw/rgw_bucket_logging_types.h new file mode 100644 index 00000000000..46db8a8e7f7 --- /dev/null +++ b/src/rgw/rgw_bucket_logging_types.h @@ -0,0 +1,59 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab ft=cpp + +#pragma once + +#include +#include +#include +#include +#include "common/ceph_time.h" +#include "rgw_sal_fwd.h" + +namespace rgw::bucketlogging { + +// Per-record input metadata for log_record(). The req_state* overloads populate +// this from the request state; no-req_state callers (e.g. the lifecycle thread) +// build it directly. Non-owning views point into request-lifetime storage. +struct record_input { + rgw::sal::Bucket* bucket{nullptr}; + // Requester field for Standard records. HTTP callers: assumed-role ARN, + // account name, or user id. LC callers: the source bucket's owner. + // std::string (not view) because the populate code builds it in place. + std::string user_or_account; + ceph::coarse_real_time time; + // Object's version id for the record. HTTP callers derive this from the + // request's object; LC callers pass the pre-deletion instance. + std::string_view version_id; + // Where to write the Journal-mode error message on failure. HTTP callers + // point at s->err.message; no-req_state callers leave it null. + std::string* journal_err_out{nullptr}; + + // Standard-mode-only fields. Views point at request-lifetime storage in + // req_state / env_map. The four std::string fields below are built in + // place by the populate code and therefore cannot be views. + std::chrono::milliseconds time_elapsed{}; + std::string_view remote_addr; + std::string_view trans_id; + std::string_view method; + std::string_view request_uri; + std::string_view request_params; + std::string_view referrer; + std::string_view user_agent; + std::string_view ssl_cipher; + std::string_view tls_version; + std::string fqdn; // appended in place with the domain suffix + std::string_view x_amz_id_2; + std::string aws_version; // written by get_aws_version_and_auth_type + std::string auth_type; // written by get_aws_version_and_auth_type + int http_ret{0}; + std::string_view err_code; + size_t content_length{0}; + bool granted_by_acl{false}; + + // For COPY operations (used only with log_source_bucket=true, Standard mode). + const sal::Object* src_object{nullptr}; + std::string_view src_bucket_name; +}; + +} // namespace rgw::bucketlogging diff --git a/src/rgw/rgw_lc.cc b/src/rgw/rgw_lc.cc index fdfbbc5abf2..8b6adba2b8c 100644 --- a/src/rgw/rgw_lc.cc +++ b/src/rgw/rgw_lc.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include "include/scope_guard.h" #include "include/function2.hpp" @@ -34,6 +35,8 @@ #include "rgw_sal.h" #include "rgw_multi_del.h" #include "rgw_multipart_meta_filter.h" +#include "rgw_bucket_logging.h" +#include "rgw_bucket_logging_types.h" #include "fmt/format.h" @@ -704,6 +707,39 @@ static void send_notification(const DoutPrefixProvider* dpp, } } +// Op name used in journal records for LC-driven deletions. +static const std::string lifecycle_delete_object_op = "LIFECYCLE.DELETE.OBJECT"; + +// LC has no req_state, so the source bucket's owner stands in as the +// identity for the log-bucket write permission check. +static void send_log_record(const DoutPrefixProvider* dpp, + optional_yield y, + rgw::sal::Driver* driver, + rgw::sal::Object* obj, + rgw::sal::Bucket* bucket, + const std::string& etag, + uint64_t size, + const std::string& version_id, + const std::string& op_name) { + rgw::bucketlogging::record_input input; + input.bucket = bucket; + input.user_or_account = to_string(bucket->get_owner()); + input.time = ceph::coarse_real_time::clock::now(); + input.version_id = version_id; + + const int ret = rgw::bucketlogging::log_record( + driver, + rgw::bucketlogging::LoggingType::Journal, + obj, input, op_name, etag, size, dpp, y, + /*async_completion=*/true, + /*log_source_bucket=*/false); + if (ret < 0) { + ldpp_dout(dpp, 1) << "WARNING: bucket logging failed for lc object: " + << obj->get_name() << " op: " << op_name + << " ret: " << ret << dendl; + } +} + /* do all zones in the zone group process LC? */ static bool zonegroup_lc_check(const DoutPrefixProvider *dpp, rgw::sal::Zone* zone) { @@ -729,7 +765,8 @@ static bool zonegroup_lc_check(const DoutPrefixProvider *dpp, rgw::sal::Zone* zo static int remove_expired_obj(const DoutPrefixProvider* dpp, optional_yield y, lc_op_ctx& oc, bool remove_indeed, - const rgw::notify::EventTypeList& event_types) { + const rgw::notify::EventTypeList& event_types, + boost::optional log_op_name = boost::none) { int ret{0}; auto& driver = oc.driver; auto& bucket_info = oc.bucket->get_info(); @@ -764,7 +801,8 @@ static int remove_expired_obj(const DoutPrefixProvider* dpp, } auto have_notify = !event_types.empty(); - if (have_notify) { + // etag is needed for both notifications and bucket-logging records + if (have_notify || log_op_name) { auto attrset = obj->get_attrs(); auto iter = attrset.find(RGW_ATTR_ETAG); if (iter != attrset.end()) { @@ -796,6 +834,10 @@ static int remove_expired_obj(const DoutPrefixProvider* dpp, send_notification(dpp, y, driver, obj.get(), oc.bucket, etag, size, version_id, event_types); } + if (log_op_name) { + send_log_record(dpp, y, driver, obj.get(), oc.bucket, etag, size, + version_id, *log_op_name); + } } return ret; @@ -1300,7 +1342,8 @@ public: /* ! o.is_delete_marker() */ r = remove_expired_obj(oc.dpp, y, oc, !oc.bucket->versioning_enabled(), {rgw::notify::ObjectExpirationCurrent, - rgw::notify::LifecycleExpirationDelete}); + rgw::notify::LifecycleExpirationDelete}, + lifecycle_delete_object_op); if (r < 0) { ldpp_dout(oc.dpp, 0) << "ERROR: remove_expired_obj " << oc.bucket << ":" << o.key @@ -1357,7 +1400,8 @@ public: auto& o = oc.o; int r = remove_expired_obj(oc.dpp, y, oc, true, {rgw::notify::LifecycleExpirationDelete, - rgw::notify::ObjectExpirationNoncurrent}); + rgw::notify::ObjectExpirationNoncurrent}, + lifecycle_delete_object_op); if (r < 0) { ldpp_dout(oc.dpp, 0) << "ERROR: remove_expired_obj (non-current expiration) " << oc.bucket << ":" << o.key @@ -1488,20 +1532,23 @@ public: */ if (! oc.bucket->versioning_enabled()) { ret = - remove_expired_obj(oc.dpp, y, oc, true, {/* no delete notify expected */}); + remove_expired_obj(oc.dpp, y, oc, true, {/* no delete notify expected */}, + lifecycle_delete_object_op); ldpp_dout(oc.dpp, 20) << "delete_tier_obj Object(key:" << oc.o.key << ") not versioned flags: " << oc.o.flags << dendl; } else { /* versioned */ if (oc.o.is_current() && !oc.o.is_delete_marker()) { - ret = remove_expired_obj(oc.dpp, y, oc, false, {/* no delete notify expected */}); + ret = remove_expired_obj(oc.dpp, y, oc, false, {/* no delete notify expected */}, + lifecycle_delete_object_op); ldpp_dout(oc.dpp, 20) << "delete_tier_obj Object(key:" << oc.o.key << ") current & not delete_marker" << " versioned_epoch: " << oc.o.versioned_epoch << "flags: " << oc.o.flags << dendl; } else { ret = remove_expired_obj(oc.dpp, y, oc, true, - {/* no delete notify expected */}); + {/* no delete notify expected */}, + lifecycle_delete_object_op); ldpp_dout(oc.dpp, 20) << "delete_tier_obj Object(key:" << oc.o.key << ") not current " << "versioned_epoch: " << oc.o.versioned_epoch diff --git a/src/rgw/rgw_rest_bucket_logging.cc b/src/rgw/rgw_rest_bucket_logging.cc index 9aa03204c3c..60c74946b21 100644 --- a/src/rgw/rgw_rest_bucket_logging.cc +++ b/src/rgw/rgw_rest_bucket_logging.cc @@ -323,7 +323,7 @@ class RGWPutBucketLoggingOp : public RGWDefaultResponseOp { obj_name, this, region, - src_bucket, + src_bucket.get(), y, false, // rollover should happen even if commit failed &objv_tracker, @@ -436,7 +436,7 @@ class RGWPostBucketLoggingOp : public RGWDefaultResponseOp { ldpp_dout(this, 5) << "INFO: no pending logging object in logging bucket '" << target_bucket_id << "'. new object should be created" << dendl; } const auto region = driver->get_zone()->get_zonegroup().get_api_name(); - op_ret = rgw::bucketlogging::rollover_logging_object(configuration, target_bucket, obj_name, this, region, source_bucket, y, true, &objv_tracker, false, &old_obj); + op_ret = rgw::bucketlogging::rollover_logging_object(configuration, target_bucket, obj_name, this, region, source_bucket.get(), y, true, &objv_tracker, false, &old_obj); if (op_ret < 0) { ldpp_dout(this, 1) << "ERROR: failed to flush pending logging object '" << obj_name << "'" << " to logging bucket '" << target_bucket_id << "'. " diff --git a/src/test/rgw/bucket_logging/test_bucket_logging.py b/src/test/rgw/bucket_logging/test_bucket_logging.py index f85aec6bc89..9e87c30e86c 100644 --- a/src/test/rgw/bucket_logging/test_bucket_logging.py +++ b/src/test/rgw/bucket_logging/test_bucket_logging.py @@ -273,6 +273,120 @@ def cleanup_bucket(s3_client, bucket_name): log.warning(f"Error cleaning up bucket {bucket_name}: {e}") +def cleanup_versioned_bucket(s3_client, bucket_name): + """Delete all object versions and delete-markers, then the bucket.""" + try: + resp = s3_client.list_object_versions(Bucket=bucket_name) + for v in resp.get('Versions', []): + s3_client.delete_object(Bucket=bucket_name, Key=v['Key'], VersionId=v['VersionId']) + for d in resp.get('DeleteMarkers', []): + s3_client.delete_object(Bucket=bucket_name, Key=d['Key'], VersionId=d['VersionId']) + s3_client.delete_bucket(Bucket=bucket_name) + log.debug(f"Deleted versioned bucket: {bucket_name}") + except ClientError as e: + log.warning(f"Error cleaning up versioned bucket {bucket_name}: {e}") + + +def ceph(args, **kwargs): + cmd = [test_path + 'test-rgw-call.sh', 'call_ceph', 'noname'] + args + return bash(cmd, **kwargs) + + +def set_lc_debug_interval(seconds): + # Each "1 day" in an LC rule becomes `seconds` real seconds. + return ceph(['config', 'set', 'client', 'rgw_lc_debug_interval', str(seconds)]) + + +def trigger_lc_processing(): + return admin(['lc', 'process']) + + +def wait_for_object_gone(s3_client, bucket, key, timeout=30, interval=1): + deadline = time.time() + timeout + while time.time() < deadline: + resp = s3_client.list_objects_v2(Bucket=bucket, Prefix=key) + if not any(c['Key'] == key for c in resp.get('Contents', [])): + return True + time.sleep(interval) + return False + + +def enable_versioning(s3_client, bucket): + s3_client.put_bucket_versioning( + Bucket=bucket, + VersioningConfiguration={'Status': 'Enabled'}, + ) + + +def apply_lc_config(s3_client, bucket, rules): + s3_client.put_bucket_lifecycle_configuration( + Bucket=bucket, + LifecycleConfiguration={'Rules': rules}, + ) + + +def make_lc_rule(prefix='', rule_id='rule', **action): + return { + 'ID': rule_id, + 'Status': 'Enabled', + 'Filter': {'Prefix': prefix}, + **action, + } + + +def create_orphan_delete_marker(s3_client, bucket, key): + v1 = s3_client.put_object(Bucket=bucket, Key=key, Body=b'data')['VersionId'] + s3_client.delete_object(Bucket=bucket, Key=key) + s3_client.delete_object(Bucket=bucket, Key=key, VersionId=v1) + + +# {bucket_owner} {bucket_name} [{date}] {op_name} {key} {size} {version_id} {etag} +_JOURNAL_RECORD_RE = re.compile( + r'^(\S+)\s+(\S+)\s+\[([^\]]+)\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$' +) + + +def parse_journal_record(line): + m = _JOURNAL_RECORD_RE.match(line) + if not m: + return None + return { + 'bucket_owner': m.group(1), + 'bucket_name': m.group(2), + 'time': m.group(3), + 'op_name': m.group(4), + 'key': m.group(5), + 'size': m.group(6), + 'version_id': m.group(7), + 'etag': m.group(8), + } + + +def read_journal_records(s3_client, log_bucket, source_bucket, prefix=None, settle_time=5): + # settle_time waits for async log writes (async_completion=true) to drain + # before we flush; without it the pending log object may still be empty. + if prefix is None: + prefix = f'{source_bucket}/' + time.sleep(settle_time) + admin(['bucket', 'logging', 'flush', '--bucket', source_bucket]) + resp = s3_client.list_objects_v2(Bucket=log_bucket, Prefix=prefix) + keys = sorted(obj['Key'] for obj in resp.get('Contents', [])) + records = [] + for key in keys: + body = s3_client.get_object(Bucket=log_bucket, Key=key)['Body'].read().decode('utf-8') + for line in body.splitlines(): + parsed = parse_journal_record(line) + if parsed is not None: + records.append(parsed) + return records + + +@pytest.fixture +def lc_fast(): + set_lc_debug_interval(5) + yield 5 + + ##################### # bucket logging tests ##################### @@ -736,4 +850,185 @@ def test_logging_commands_unconfigured_bucket(s3_client): assert not output.strip(), f"{cmd} on unconfigured bucket should produce empty stdout: {output}" finally: - cleanup_bucket(s3_client, bucket) \ No newline at end of file + cleanup_bucket(s3_client, bucket) + + +@pytest.mark.basic_test +def test_lc_expiration_logs_journal_record(s3_client, logging_type, lc_fast): + """LC Expiration on a non-versioned bucket emits LIFECYCLE.DELETE.OBJECT""" + if logging_type != 'Journal': + pytest.skip("LC bucket logging is Journal-mode-only") + source = gen_bucket_name("lc-source") + log_bucket = gen_bucket_name("lc-log") + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket, 'Journal') + apply_lc_config(s3_client, source, [make_lc_rule(Expiration={'Days': 1})]) + s3_client.put_object(Bucket=source, Key='obj.txt', Body=b'data') + + time.sleep(lc_fast + 2) + trigger_lc_processing() + assert wait_for_object_gone(s3_client, source, 'obj.txt'), "LC did not delete the object" + + records = read_journal_records(s3_client, log_bucket, source) + lc_records = [r for r in records if r['op_name'] == 'LIFECYCLE.DELETE.OBJECT'] + assert len(lc_records) == 1, f"expected 1 LIFECYCLE.DELETE.OBJECT record, got {len(lc_records)}: {records}" + assert lc_records[0]['key'] == 'obj.txt' + assert lc_records[0]['bucket_name'] == source + + finally: + cleanup_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + +@pytest.mark.basic_test +def test_lc_versioned_current_expiration_logs_journal_record(s3_client, logging_type, lc_fast): + """LC Expiration on a versioned bucket creates a delete marker and emits LIFECYCLE.DELETE.OBJECT for the expired current version.""" + if logging_type != 'Journal': + pytest.skip("LC bucket logging is Journal-mode-only") + source = gen_bucket_name("lc-source") + log_bucket = gen_bucket_name("lc-log") + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket, 'Journal') + enable_versioning(s3_client, source) + + apply_lc_config(s3_client, source, [make_lc_rule(Expiration={'Days': 1})]) + v1 = s3_client.put_object(Bucket=source, Key='obj.txt', Body=b'data')['VersionId'] + + time.sleep(lc_fast + 2) + trigger_lc_processing() + time.sleep(lc_fast) + + resp = s3_client.list_object_versions(Bucket=source, Prefix='obj.txt') + versions = [v['VersionId'] for v in resp.get('Versions', [])] + delete_markers = resp.get('DeleteMarkers', []) + assert versions == [v1], f"expected v1 to remain as noncurrent, got {versions}" + assert len(delete_markers) == 1, f"expected 1 delete marker, got {len(delete_markers)}" + + records = read_journal_records(s3_client, log_bucket, source) + lc_records = [r for r in records if r['op_name'] == 'LIFECYCLE.DELETE.OBJECT'] + assert len(lc_records) == 1, f"expected 1 LIFECYCLE.DELETE.OBJECT record, got {len(lc_records)}" + assert lc_records[0]['version_id'] == v1 + assert lc_records[0]['key'] == 'obj.txt' + assert lc_records[0]['bucket_name'] == source + + finally: + cleanup_versioned_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + +@pytest.mark.basic_test +def test_lc_noncurrent_expiration_logs_journal_record(s3_client, logging_type, lc_fast): + """LC NoncurrentVersionExpiration emits LIFECYCLE.DELETE.OBJECT for the deleted noncurrent version.""" + if logging_type != 'Journal': + pytest.skip("LC bucket logging is Journal-mode-only") + source = gen_bucket_name("lc-source") + log_bucket = gen_bucket_name("lc-log") + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket, 'Journal') + enable_versioning(s3_client, source) + + apply_lc_config(s3_client, source, [make_lc_rule(NoncurrentVersionExpiration={'NoncurrentDays': 1})]) + v1 = s3_client.put_object(Bucket=source, Key='obj.txt', Body=b'v1')['VersionId'] + v2 = s3_client.put_object(Bucket=source, Key='obj.txt', Body=b'v2')['VersionId'] # v1 -> noncurrent + + time.sleep(lc_fast + 2) + trigger_lc_processing() + time.sleep(lc_fast) + + resp = s3_client.list_object_versions(Bucket=source, Prefix='obj.txt') + remaining = [v['VersionId'] for v in resp.get('Versions', [])] + assert remaining == [v2], f"expected only current v2 to remain, got {remaining}" + + records = read_journal_records(s3_client, log_bucket, source) + lc_records = [r for r in records if r['op_name'] == 'LIFECYCLE.DELETE.OBJECT'] + assert len(lc_records) == 1, f"expected 1 LIFECYCLE.DELETE.OBJECT record, got {len(lc_records)}" + assert lc_records[0]['version_id'] == v1 + assert lc_records[0]['key'] == 'obj.txt' + assert lc_records[0]['bucket_name'] == source + + finally: + cleanup_versioned_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + +@pytest.mark.basic_test +def test_lc_dm_expiration_does_not_log(s3_client, logging_type, lc_fast): + """LC ExpiredObjectDeleteMarker must not emit LIFECYCLE.DELETE.OBJECT records.""" + if logging_type != 'Journal': + pytest.skip("LC bucket logging is Journal-mode-only") + source = gen_bucket_name("lc-source") + log_bucket = gen_bucket_name("lc-log") + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket, 'Journal') + enable_versioning(s3_client, source) + + create_orphan_delete_marker(s3_client, source, 'obj.txt') + apply_lc_config(s3_client, source, [make_lc_rule(Expiration={'ExpiredObjectDeleteMarker': True})]) + + time.sleep(lc_fast + 2) + trigger_lc_processing() + time.sleep(lc_fast) + + resp = s3_client.list_object_versions(Bucket=source, Prefix='obj.txt') + assert not resp.get('Versions') and not resp.get('DeleteMarkers'), "delete marker was not expired, the rule never fired" + records = read_journal_records(s3_client, log_bucket, source) + lc_records = [r for r in records if r['op_name'] == 'LIFECYCLE.DELETE.OBJECT'] + assert len(lc_records) == 0, f"expected 0 LIFECYCLE.DELETE.OBJECT records, got {len(lc_records)}" + + finally: + cleanup_versioned_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + +@pytest.mark.basic_test +def test_lc_current_expiration_dm_branch_does_not_log(s3_client, logging_type, lc_fast): + """LC Expiration's delete-marker branch must not emit LIFECYCLE.DELETE.OBJECT records.""" + if logging_type != 'Journal': + pytest.skip("LC bucket logging is Journal-mode-only") + source = gen_bucket_name("lc-source") + log_bucket = gen_bucket_name("lc-log") + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket, 'Journal') + enable_versioning(s3_client, source) + + create_orphan_delete_marker(s3_client, source, 'obj.txt') + apply_lc_config(s3_client, source, [make_lc_rule(Expiration={'Days': 1})]) + + time.sleep(lc_fast + 2) + trigger_lc_processing() + time.sleep(lc_fast) + + resp = s3_client.list_object_versions(Bucket=source, Prefix='obj.txt') + assert not resp.get('Versions') and not resp.get('DeleteMarkers'), "delete marker was not expired -- the rule never fired" + + records = read_journal_records(s3_client, log_bucket, source) + lc_records = [r for r in records if r['op_name'] == 'LIFECYCLE.DELETE.OBJECT'] + assert len(lc_records) == 0, f"expected 0 LIFECYCLE.DELETE.OBJECT records, got {len(lc_records)}" + + finally: + cleanup_versioned_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + +@pytest.mark.basic_test +def test_lc_runs_safely_without_logging_config(s3_client, lc_fast): + """LC runs successfully on a bucket without bucket-logging configured.""" + source = gen_bucket_name("lc-nolog") + + try: + s3_client.create_bucket(Bucket=source) + apply_lc_config(s3_client, source, [make_lc_rule(Expiration={'Days': 1})]) + s3_client.put_object(Bucket=source, Key='obj.txt', Body=b'data') + + time.sleep(lc_fast + 2) + _, ret = trigger_lc_processing() + assert ret == 0, f"radosgw-admin lc process failed: rc={ret}" + assert wait_for_object_gone(s3_client, source, 'obj.txt'), "LC did not delete the object" + + finally: + cleanup_bucket(s3_client, source) \ No newline at end of file From 391383dae2efb70b1eb346d1073db0ecec1250e2 Mon Sep 17 00:00:00 2001 From: Shubha Jain Date: Tue, 9 Jun 2026 19:04:01 +0530 Subject: [PATCH 176/596] cephadm: report intentionally stopped daemons as stopped node-exporter exits with status 143 when terminated via SIGTERM. Systemd treats this as a failure by default, causing cephadm to report an intentionally stopped daemon as being in an error state. Allow daemon forms to provide daemon-specific success exit status codes and generate a corresponding systemd SuccessExitStatus drop-in when required. For node-exporter, configure exit status 143 as successful so intentional stops are reported as stopped rather than failed. Fixes: https://tracker.ceph.com/issues/68863 Signed-off-by: Shubha Jain --- src/cephadm/cephadm.py | 6 +- src/cephadm/cephadmlib/container_types.py | 1 + src/cephadm/cephadmlib/daemons/monitoring.py | 5 +- src/cephadm/cephadmlib/systemd_unit.py | 38 +++++++++ src/cephadm/tests/test_unit_file.py | 85 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 7cf65220c80..72e3c77e75e 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -1312,7 +1312,11 @@ def deploy_daemon_units( DaemonSubIdentity.must(sc.identity) for sc in sidecars or [] ] systemd_unit.update_files( - ctx, ident, init_container_ids=ic_ids, sidecar_ids=sc_ids + ctx, + ident, + init_container_ids=ic_ids, + sidecar_ids=sc_ids, + success_exit_status=container.success_exit_status, ) call_throws(ctx, ['systemctl', 'daemon-reload']) diff --git a/src/cephadm/cephadmlib/container_types.py b/src/cephadm/cephadmlib/container_types.py index c38a27d8daa..6c18ffdb4c1 100644 --- a/src/cephadm/cephadmlib/container_types.py +++ b/src/cephadm/cephadmlib/container_types.py @@ -254,6 +254,7 @@ class CephContainer(BasicContainer): self.remove = True self.ipc = 'host' self.network = 'host' if self.host_network else '' + self.success_exit_status: Optional[List[int]] = None @classmethod def for_daemon( diff --git a/src/cephadm/cephadmlib/daemons/monitoring.py b/src/cephadm/cephadmlib/daemons/monitoring.py index d93fc6c68ec..a992ca9db16 100644 --- a/src/cephadm/cephadmlib/daemons/monitoring.py +++ b/src/cephadm/cephadmlib/daemons/monitoring.py @@ -215,7 +215,10 @@ class Monitoring(ContainerDaemonForm): def container(self, ctx: CephadmContext) -> CephContainer: self._prevalidate(ctx) ctr = daemon_to_container(ctx, self) - return to_deployment_container(ctx, ctr) + ctr = to_deployment_container(ctx, ctr) + if self.identity.daemon_type == 'node-exporter': + ctr.success_exit_status = [143] + return ctr def uid_gid(self, ctx: CephadmContext) -> Tuple[int, int]: return self.extract_uid_gid(ctx, self.identity.daemon_type) diff --git a/src/cephadm/cephadmlib/systemd_unit.py b/src/cephadm/cephadmlib/systemd_unit.py index d3543174a8d..c76fee4e747 100644 --- a/src/cephadm/cephadmlib/systemd_unit.py +++ b/src/cephadm/cephadmlib/systemd_unit.py @@ -16,6 +16,7 @@ from .logging import write_cluster_logrotate_config _DROP_IN_FILENAME = '99-cephadm.conf' +_SUCCESS_EXIT_STATUS_DROP_IN_FMT = '90-cephadm-{daemon_type}.conf' class PathInfo: @@ -100,6 +101,39 @@ def _write_sidecar_unit_file( ) +def _install_success_exit_status_drop_in( + pinfo: PathInfo, + ident: DaemonIdentity, + exit_codes: List[int], +) -> None: + """Install a drop-in that marks extra exit codes as success. + + Container runtimes exit with 143 (128 + SIGTERM) on a clean stop. + Without an explicit SuccessExitStatus directive systemd treats this + as a failure, causing cephadm to report the daemon as ``error`` + instead of ``stopped``. + + The list of exit codes is carried on the ``CephContainer`` object + and populated by the daemon's ``container()`` method, keeping + daemon-specific knowledge out of this module. + """ + if not exit_codes: + return + pinfo.drop_in_file.parent.mkdir(parents=True, exist_ok=True) + fname = _SUCCESS_EXIT_STATUS_DROP_IN_FMT.format( + daemon_type=ident.daemon_type + ) + dest = pinfo.drop_in_file.parent / fname + codes = ' '.join(str(c) for c in exit_codes) + content = ( + '# generated by cephadm\n' + '[Service]\n' + f'SuccessExitStatus={codes}\n' + ) + with write_new(dest, perms=None) as f: + f.write(content) + + def _install_extended_systemd_services( ctx: CephadmContext, pinfo: PathInfo, @@ -203,6 +237,7 @@ def update_files( *, init_container_ids: Optional[List[DaemonSubIdentity]] = None, sidecar_ids: Optional[List[DaemonSubIdentity]] = None, + success_exit_status: Optional[List[int]] = None, ) -> None: _install_base_units(ctx, ident.fsid) unit = _get_unit_file(ctx, ident.fsid) @@ -212,6 +247,9 @@ def update_files( _install_extended_systemd_services( ctx, pathinfo, ident, bool(init_container_ids) ) + _install_success_exit_status_drop_in( + pathinfo, ident, success_exit_status or [] + ) def sidecars_from_dropin( diff --git a/src/cephadm/tests/test_unit_file.py b/src/cephadm/tests/test_unit_file.py index 74cd89c1a82..43c2bd24653 100644 --- a/src/cephadm/tests/test_unit_file.py +++ b/src/cephadm/tests/test_unit_file.py @@ -19,6 +19,7 @@ from tests.fixtures import ( from cephadmlib import context from cephadmlib import systemd_unit from cephadmlib.constants import CGROUPS_SPLIT_PODMAN_VERSION +from cephadmlib.daemon_identity import DaemonIdentity _cephadm = import_cephadm() @@ -147,3 +148,87 @@ def test_new_podman(): '[Install]', 'WantedBy=ceph-9b9d7609-f4d5-4aba-94c8-effa764d96c9.target', ] + + +_FSID = '9b9d7609-f4d5-4aba-94c8-effa764d96c9' + + +def test_success_exit_status_drop_in_written(tmp_path): + """When exit codes are provided, a drop-in with SuccessExitStatus is + written into the service's .d/ directory.""" + ident = DaemonIdentity(_FSID, 'node-exporter', 'ceph-node-00') + pinfo = systemd_unit.PathInfo(tmp_path, ident) + systemd_unit._install_success_exit_status_drop_in( + pinfo, ident, [143] + ) + fname = systemd_unit._SUCCESS_EXIT_STATUS_DROP_IN_FMT.format( + daemon_type='node-exporter' + ) + drop_in = pinfo.drop_in_file.parent / fname + assert drop_in.exists() + assert fname == '90-cephadm-node-exporter.conf' + content = drop_in.read_text() + assert '[Service]' in content + assert 'SuccessExitStatus=143' in content + + +def test_success_exit_status_drop_in_not_written_for_empty_list(tmp_path): + """When exit codes list is empty, no drop-in is created.""" + ident = DaemonIdentity(_FSID, 'mgr', 'host1') + pinfo = systemd_unit.PathInfo(tmp_path, ident) + systemd_unit._install_success_exit_status_drop_in( + pinfo, ident, [] + ) + fname = systemd_unit._SUCCESS_EXIT_STATUS_DROP_IN_FMT.format( + daemon_type='mgr' + ) + drop_in = pinfo.drop_in_file.parent / fname + assert not drop_in.exists() + + +@mock.patch('cephadmlib.daemons.monitoring.Monitoring._prevalidate') +@mock.patch('cephadmlib.daemons.monitoring.daemon_to_container') +@mock.patch('cephadmlib.daemons.monitoring.to_deployment_container') +def test_monitoring_node_exporter_container_has_success_exit_status( + _to_deploy, _to_ctr, _preval +): + """Monitoring.container() sets success_exit_status=[143] for + node-exporter.""" + from cephadmlib.daemons.monitoring import Monitoring + from cephadmlib.container_types import CephContainer + + ctx = context.CephadmContext() + ctx.container_engine = mock_podman() + fake_ctr = mock.MagicMock(spec=CephContainer) + fake_ctr.success_exit_status = None + _to_deploy.return_value = fake_ctr + ident = DaemonIdentity(_FSID, 'node-exporter', 'host1') + daemon = Monitoring(ctx, ident) + ctr = daemon.container(ctx) + assert ctr.success_exit_status == [143] + + +@mock.patch('cephadmlib.daemons.monitoring.Monitoring._prevalidate') +@mock.patch('cephadmlib.daemons.monitoring.daemon_to_container') +@mock.patch('cephadmlib.daemons.monitoring.to_deployment_container') +@pytest.mark.parametrize( + 'daemon_type', + ['prometheus', 'grafana', 'alertmanager', 'loki', 'promtail'], +) +def test_monitoring_other_types_no_success_exit_status( + _to_deploy, _to_ctr, _preval, daemon_type +): + """Monitoring.container() leaves success_exit_status as None for + non-node-exporter types.""" + from cephadmlib.daemons.monitoring import Monitoring + from cephadmlib.container_types import CephContainer + + ctx = context.CephadmContext() + ctx.container_engine = mock_podman() + fake_ctr = mock.MagicMock(spec=CephContainer) + fake_ctr.success_exit_status = None + _to_deploy.return_value = fake_ctr + ident = DaemonIdentity(_FSID, daemon_type, 'host1') + daemon = Monitoring(ctx, ident) + ctr = daemon.container(ctx) + assert ctr.success_exit_status is None From 91da0fd6ae6c0b82a4ff59a3b5786c4c34be2865 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 23 Feb 2026 10:39:49 +0100 Subject: [PATCH 177/596] mgr/ceph_secrets: add secret reference types and parsing helpers Introduce the shared types and parsing logic used across the secrets module: secret scopes, secret references, and the exception hierarchy. Includes validation for all supported addressing forms and clear error messages on malformed input. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets_types.py | 342 +++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets_types.py diff --git a/src/pybind/mgr/ceph_secrets_types.py b/src/pybind/mgr/ceph_secrets_types.py new file mode 100644 index 00000000000..ae2ccd58672 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets_types.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from typing import Tuple +from urllib.parse import urlparse, quote + + +# Internal URI scheme for secret references. +# Canonical form has no authority: secret:///... +SECRET_SCHEME = 'secret' + + +class CephSecretException(Exception): + pass + + +class CephSecretDataError(CephSecretException): + pass + + +class CephSecretNotFoundError(CephSecretException): + pass + + +# --------------------------------------------------------------------------- +# Segment grammar +# --------------------------------------------------------------------------- +# Accepted characters: alphanumeric, dot, hyphen, underscore. +# Additional rule: a segment must not end with '.' (Vault API restriction). +# Applies to: namespace, global name, service/host target and name, +# and each individual segment of a custom path. + +_SEGMENT_RE = re.compile(r'^[A-Za-z0-9._-]+$') + + +def _validate_segment(label: str, value: str) -> None: + """Raise ValueError with a field-level message. No URI context — callers re-wrap.""" + if not isinstance(value, str): + raise ValueError(f'{label} must be a string') + if not value: + raise ValueError(f'{label} must not be empty') + if not _SEGMENT_RE.fullmatch(value): + raise ValueError(f'{label} contains unsupported characters') + if value.endswith('.'): + raise ValueError(f"{label} must not end with '.'") + + +def validate_secret_namespace(namespace: str) -> None: + """Validate a secret namespace segment. Raises ValueError.""" + _validate_segment('namespace', namespace) + + +def _validate_custom_path(value: str) -> None: + """Validate a slash-delimited custom path. Raises ValueError.""" + if not isinstance(value, str): + raise ValueError('custom path must be a string') + if not value: + raise ValueError('custom path must not be empty') + parts = value.split('/') + if any(p == '' for p in parts): + raise ValueError('custom path must not contain empty segments') + for part in parts: + _validate_segment('custom path segment', part) + + +# --------------------------------------------------------------------------- +# URI serialisation helpers +# --------------------------------------------------------------------------- +# With strict segment validation, quoting is effectively a no-op. Kept as +# defensive correctness for round-trip safety. + +def _quote_segment(v: str) -> str: + """Percent-encode a single path segment (slashes not preserved).""" + return quote(v, safe='') + + +def _quote_custom_path(v: str) -> str: + """Percent-encode a custom path, preserving '/' as segment delimiters.""" + return quote(v, safe='/') + + +# --------------------------------------------------------------------------- +# Scope +# --------------------------------------------------------------------------- + +class SecretScope(str, Enum): + GLOBAL = 'global' + SERVICE = 'service' + HOST = 'host' + CUSTOM = 'custom' + + @classmethod + def from_str(cls, s: str) -> 'SecretScope': + try: + return SecretScope(s) + except Exception as e: + allowed = ', '.join(x.value for x in SecretScope) + raise CephSecretException( + f'Invalid secret scope {s!r}. Expected one of: {allowed}' + ) from e + + def validate_fields(self, target: str, name: str) -> None: + """Validate target/name for this scope. Raises ValueError.""" + if self == SecretScope.GLOBAL: + if target: + raise ValueError('target must be empty for global scope') + _validate_segment('name', name) + + elif self == SecretScope.CUSTOM: + if target: + raise ValueError('target must be empty for custom scope') + _validate_custom_path(name) + + elif self in (SecretScope.SERVICE, SecretScope.HOST): + _validate_segment('target', target) + _validate_segment('name', name) + + else: + raise ValueError(f'unsupported scope {self!r}') + + +# --------------------------------------------------------------------------- +# SecretRef +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SecretRef: + namespace: str + scope: SecretScope + target: str + name: str + + def __post_init__(self) -> None: + try: + scope = ( + self.scope + if isinstance(self.scope, SecretScope) + else SecretScope.from_str(str(self.scope)) + ) + except CephSecretException as e: + raise ValueError(str(e)) from e + object.__setattr__(self, 'scope', scope) + _validate_segment('namespace', self.namespace) + scope.validate_fields(self.target, self.name) + + def ident(self) -> Tuple[str, str, str, str]: + return (self.namespace, self.scope.value, self.target, self.name) + + def to_uri(self) -> str: + ns = _quote_segment(self.namespace) + scope = self.scope.value + + if self.scope == SecretScope.CUSTOM: + return f'{SECRET_SCHEME}:/{ns}/{scope}/{_quote_custom_path(self.name)}' + + if self.scope == SecretScope.GLOBAL: + return f'{SECRET_SCHEME}:/{ns}/{scope}/{_quote_segment(self.name)}' + + return ( + f'{SECRET_SCHEME}:/{ns}/{scope}/' + f'{_quote_segment(self.target)}/{_quote_segment(self.name)}' + ) + + +@dataclass(frozen=True) +class BadSecretURI: + raw: str + error: str + namespace: str + + def to_uri(self) -> str: + return self.raw + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +def parse_secret_uri(uri: str) -> SecretRef: + """ + Parse a secret reference URI. + + Canonical forms: + secret://global/ + secret://service// + secret://host// + secret://custom/ + + All segments must match [A-Za-z0-9._-]+ and must not end with '.'. + Percent-encoding, query strings, fragments, and URI authority are not supported. + """ + try: + if not isinstance(uri, str): + raise CephSecretException('secret uri must be a string') + + parsed = urlparse(uri) + if parsed.scheme != SECRET_SCHEME: + raise CephSecretException(f'Not a secret uri: {uri!r}') + if parsed.query or parsed.fragment: + raise CephSecretException( + f'Invalid secret uri {uri!r}: query strings and fragments are not supported' + ) + if uri.startswith(f'{SECRET_SCHEME}://') or parsed.netloc: + raise CephSecretException( + f'Invalid secret uri {uri!r}: authority is not supported; ' + f'use secret:///' + ) + + # Canonical form: secret:///. + path = parsed.path or '' + if not path.startswith('/'): + raise CephSecretException( + f'Invalid secret uri {uri!r}: expected secret:///' + ) + + # Reject percent-encoding: the strict segment grammar has a single canonical + # spelling for every valid identifier. Accepting encoded aliases (e.g. + # db%2Dpassword → db-password, app%2Fdb → app/db) would silently create + # multiple URIs that resolve to the same secret. + if '%' in path: + raise CephSecretException( + f'Invalid secret uri {uri!r}: percent-encoding is not supported' + ) + + # Split on raw '/' — no unquote() needed since percent-encoding is rejected. + namespace_raw, sep, remainder = path.lstrip('/').partition('/') + scope_raw, sep2, rest_raw = remainder.partition('/') if sep else ('', '', '') + if not (sep and sep2): + raise CephSecretException( + f'Invalid secret uri {uri!r}: expected secret:///' + ) + + scope = SecretScope.from_str(scope_raw) + + if scope in (SecretScope.GLOBAL, SecretScope.CUSTOM): + target = '' + name = rest_raw + else: + target_raw, _, name_raw = rest_raw.partition('/') + target = target_raw + name = name_raw + + # Single construction point: SecretRef validates all fields. + # ValueError is re-raised with the original URI for user-facing messages. + try: + return SecretRef(namespace=namespace_raw, scope=scope, target=target, name=name) + except ValueError as e: + raise CephSecretException(f'Invalid secret uri {uri!r}: {e}') from e + + except CephSecretException: + raise + except ValueError as e: + raise CephSecretException(str(e)) from e + except Exception as e: + raise CephSecretException(f'Invalid secret uri {uri!r}: {e}') from e + + +def _coerce_scope(s: str) -> 'SecretScope': + # Accept both enum values ('global') and enum names ('GLOBAL'). + if not s.strip(): + raise CephSecretException('Scope must not be empty') + s_norm = s.strip() + try: + return SecretScope(s_norm) + except Exception: + try: + return SecretScope[s_norm.upper()] + except Exception: + allowed = ', '.join(x.value for x in SecretScope) + raise CephSecretException( + f'Unknown scope {s!r}. Expected one of: {allowed}' + ) + + +def parse_secret_path(path: str) -> SecretRef: + """ + Parse a secret locator path (no URI scheme, no percent-encoding): + /global/ + /service// + /host// + /custom/ + + Returns a validated SecretRef. Raises CephSecretException on any + structural or content error. + """ + if not isinstance(path, str): + raise CephSecretException('secret path must be a string') + + p = path.strip() + if not p: + raise CephSecretException('Invalid secret path: empty') + + if p.startswith('//'): + raise CephSecretException( + f"Invalid secret path {path!r}: multiple leading slashes are not allowed" + ) + p = p.lstrip('/') + + segs = p.split('/') + if any(s == '' for s in segs): + raise CephSecretException( + f"Invalid secret path {path!r}: empty segment (check for '//' or trailing '/')" + ) + if any(s != s.strip() for s in segs): + raise CephSecretException( + f"Invalid secret path {path!r}: segments must not contain leading/trailing whitespace" + ) + if len(segs) < 3: + raise CephSecretException( + f"Invalid secret path {path!r}. Use '//'." + ) + + ns, scope_s = segs[0], segs[1] + scope = _coerce_scope(scope_s) + rest = segs[2:] + + if scope == SecretScope.GLOBAL: + if len(rest) != 1: + raise CephSecretException( + f"Invalid secret path {path!r}: global scope expects '/global/'" + ) + target, name = '', rest[0] + + elif scope == SecretScope.CUSTOM: + target, name = '', '/'.join(rest) + + elif len(rest) != 2: + raise CephSecretException( + f"Invalid secret path {path!r}: {scope.value!r} scope expects " + f"'/{scope.value}//'" + ) + + else: + target, name = rest[0], rest[1] + + try: + return SecretRef(ns, scope, target, name) + except ValueError as e: + raise CephSecretException(f'Invalid secret path {path!r}: {e}') from e From 16ae0b3d2bef1b83f1a8a75d2026a9cbd5c372d8 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 26 Jan 2026 14:40:43 +0100 Subject: [PATCH 178/596] mgr/ceph_secrets: add storage backend protocol for mgr KV secrets Define a minimal backend protocol for secret persistence operations (get/set/rm/list), keeping the module implementation decoupled from the backing store details. For now we will start with monstore-db as secure KV store but the idea is to extend this to other backends such as Vault. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets/backends.py | 5 ++ src/pybind/mgr/ceph_secrets/secret_backend.py | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets/backends.py create mode 100644 src/pybind/mgr/ceph_secrets/secret_backend.py diff --git a/src/pybind/mgr/ceph_secrets/backends.py b/src/pybind/mgr/ceph_secrets/backends.py new file mode 100644 index 00000000000..83da430d7b3 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/backends.py @@ -0,0 +1,5 @@ +from .secret_store import SecretStoreMon + +BACKENDS = { + "mon": SecretStoreMon, +} diff --git a/src/pybind/mgr/ceph_secrets/secret_backend.py b/src/pybind/mgr/ceph_secrets/secret_backend.py new file mode 100644 index 00000000000..af276da7241 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/secret_backend.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List, Optional, TYPE_CHECKING +from ceph_secrets_types import SecretScope + + +if TYPE_CHECKING: + # Import only for type-checkers to avoid runtime import cycles + from .secret_store import SecretRecord + + +class SecretStorageBackend(ABC): + """ + Abstract base class for ceph secrets storage backends. + + Backends store secret instances addressed by: + (namespace, scope, target, name) + + For custom scope, target is empty and name stores the free-form path suffix. + + Epoch: + Each namespace has an independent monotonic epoch counter that is bumped + on every set and on rm only when an existing secret is actually removed. + Consumers can use get_epoch(namespace) as a cheap change-detector to + avoid re-fetching secrets when nothing changed in their namespace. + + Implementations: + - SecretStoreMon (Mon KV store) + - Vault backend (future) + """ + + @abstractmethod + def get(self, namespace: str, scope: SecretScope, target: str, name: str) -> Optional["SecretRecord"]: + ... + + @abstractmethod + def set( + self, + namespace: str, + scope: SecretScope, + target: str, + name: str, + data: str, + user_made: bool = True, + editable: bool = True, + ) -> "SecretRecord": + ... + + @abstractmethod + def rm(self, namespace: str, scope: SecretScope, target: str, name: str) -> bool: + ... + + @abstractmethod + def ls( + self, + namespace: Optional[str] = None, + scope: Optional[SecretScope] = None, + target: Optional[str] = None, + ) -> List["SecretRecord"]: + ... + + @abstractmethod + def get_epoch(self, namespace: str) -> int: + """Return the current epoch for *namespace*.""" + ... + + @abstractmethod + def bump_epoch(self, namespace: str) -> int: + """Increment and persist the epoch for *namespace*; return the new value.""" + ... From 0937270bddae4835a89f3e637732eaa8578f61f1 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 23 Feb 2026 10:44:22 +0100 Subject: [PATCH 179/596] mgr/ceph_secrets: add SecretStoreMon mon-kv store implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SecretRecord (data, metadata, versioning, timestamps) and the canonical KV prefix (secret_store/v1/…). Add JSON serialization helpers (to_json) including the ability to omit secret data unless explicitly requested. This commit defines the “what we store and how it looks” without wiring any mgr interactions yet. Add SecretStoreMon implementing the backend using mgr’s KV store (get_store, set_store, prefix listing). Implement set/get/rm semantics, version increments, and list-by-prefix queries for namespace/scope/target. This isolates persistence logic from CLI/RPC concerns and provides deterministic record behavior for later layers. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets/secret_store.py | 536 ++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets/secret_store.py diff --git a/src/pybind/mgr/ceph_secrets/secret_store.py b/src/pybind/mgr/ceph_secrets/secret_store.py new file mode 100644 index 00000000000..c8508d8b089 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/secret_store.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple, Union + +from ceph_secrets_types import ( + SecretScope, + SecretRef, + CephSecretException, + CephSecretDataError, + validate_secret_namespace, +) +from .secret_backend import SecretStorageBackend + + +logger = logging.getLogger(__name__) + + +# Secret data lives under this prefix: secret_store/v1///... +SECRET_STORE_PREFIX = 'secret_store/v1/' + +# Storage payload schema version. Stored records are strict: unknown or missing fields are rejected +SECRET_STORE_FORMAT_VERSION = 1 + +# Per-namespace epoch keys live under a completely separate prefix so they are +# never picked up by the secret data scan in ls(). +# Key form: secret_store/meta//_epoch +SECRET_META_PREFIX = 'secret_store/meta/' + + +JsonType = Union[ + None, + bool, + int, + float, + str, + List["JsonType"], + Dict[str, "JsonType"], +] + +JsonDict = Dict[str, JsonType] + +# Secret data is an opaque string. Callers are responsible for any structure +# within it, for example JSON-encoding a dict before storing and decoding after +# retrieval. +SecretData = str + + +def _checked_namespace(namespace: str) -> str: + try: + validate_secret_namespace(namespace) + except ValueError as e: + raise CephSecretDataError(str(e)) from e + return namespace + + +def _epoch_key(namespace: str) -> str: + namespace = _checked_namespace(namespace) + return f'{SECRET_META_PREFIX}{namespace}/_epoch' + + +def _checked_ref( + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, +) -> SecretRef: + try: + if not isinstance(scope, SecretScope): + scope = SecretScope.from_str(str(scope)) + return SecretRef( + namespace=namespace, + scope=scope, + target=target or '', + name=name, + ) + except (CephSecretException, ValueError) as e: + raise CephSecretDataError(str(e)) from e + + +def _now_iso() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace('+00:00', 'Z') + ) + + +def _expect_object(label: str, value: Any) -> JsonDict: + if not isinstance(value, dict): + raise CephSecretDataError( + f'{label} must be a JSON object (got {type(value).__name__})' + ) + return value + + +def _reject_unknown_keys(label: str, payload: JsonDict, allowed: set[str]) -> None: + unknown = set(payload) - allowed + if unknown: + raise CephSecretDataError( + f'{label} contains unknown field(s): {", ".join(sorted(unknown))}' + ) + + +def _require_keys(label: str, payload: JsonDict, required: set[str]) -> None: + missing = required - set(payload) + if missing: + raise CephSecretDataError( + f'{label} is missing required field(s): {", ".join(sorted(missing))}' + ) + + +def _expect_bool(label: str, value: Any) -> bool: + if not isinstance(value, bool): + raise CephSecretDataError(f'{label} must be a boolean') + return value + + +def _expect_str(label: str, value: Any) -> str: + if not isinstance(value, str): + raise CephSecretDataError(f'{label} must be a string') + return value + + +def _expect_positive_int(label: str, value: Any) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise CephSecretDataError(f'{label} must be a positive integer') + return value + + +def _ref_json(ref: SecretRef) -> JsonDict: + return { + 'namespace': ref.namespace, + 'scope': ref.scope.value, + 'target': ref.target, + 'name': ref.name, + } + + +@dataclass(frozen=True) +class SecretMetadata: + version: int = 1 + created: str = '' + updated: str = '' + + def __post_init__(self) -> None: + _expect_positive_int('SecretMetadata.version', self.version) + _expect_str('SecretMetadata.created', self.created) + _expect_str('SecretMetadata.updated', self.updated) + + def to_json(self) -> JsonDict: + return { + 'version': self.version, + 'created': self.created, + 'updated': self.updated, + } + + @staticmethod + def from_json(payload: JsonDict) -> 'SecretMetadata': + payload = _expect_object('SecretMetadata', payload) + allowed = {'version', 'created', 'updated'} + _reject_unknown_keys('SecretMetadata', payload, allowed) + _require_keys('SecretMetadata', payload, allowed) + return SecretMetadata( + version=_expect_positive_int('SecretMetadata.version', payload['version']), + created=_expect_str('SecretMetadata.created', payload['created']), + updated=_expect_str('SecretMetadata.updated', payload['updated']), + ) + + +@dataclass(frozen=True) +class SecretPolicy: + user_made: bool = True + editable: bool = True + + def __post_init__(self) -> None: + _expect_bool('SecretPolicy.user_made', self.user_made) + _expect_bool('SecretPolicy.editable', self.editable) + + def to_json(self) -> JsonDict: + return { + 'user_made': self.user_made, + 'editable': self.editable, + } + + @staticmethod + def from_json(payload: JsonDict) -> 'SecretPolicy': + payload = _expect_object('SecretPolicy', payload) + allowed = {'user_made', 'editable'} + _reject_unknown_keys('SecretPolicy', payload, allowed) + _require_keys('SecretPolicy', payload, allowed) + return SecretPolicy( + user_made=_expect_bool('SecretPolicy.user_made', payload['user_made']), + editable=_expect_bool('SecretPolicy.editable', payload['editable']), + ) + + +@dataclass +class SecretRecord: + ref: SecretRef + data: SecretData + metadata: SecretMetadata = field(default_factory=SecretMetadata) + policy: SecretPolicy = field(default_factory=SecretPolicy) + + def __post_init__(self) -> None: + if not isinstance(self.ref, SecretRef): + raise CephSecretDataError('SecretRecord.ref must be a SecretRef') + if not isinstance(self.metadata, SecretMetadata): + raise CephSecretDataError('SecretRecord.metadata must be SecretMetadata') + if not isinstance(self.policy, SecretPolicy): + raise CephSecretDataError('SecretRecord.policy must be SecretPolicy') + if not isinstance(self.data, str): + raise CephSecretDataError('SecretRecord.data must be a string') + if self.data == '': + raise CephSecretDataError('SecretRecord.data must not be empty') + + # Compatibility/readability helpers for key construction, sorting, and callers + # that need the identity but should not mutate it directly. + @property + def namespace(self) -> str: + return self.ref.namespace + + @property + def scope(self) -> SecretScope: + return self.ref.scope + + @property + def target(self) -> str: + return self.ref.target + + @property + def name(self) -> str: + return self.ref.name + + def ident(self) -> Tuple[str, str, str, str]: + return self.ref.ident() + + def to_public_json( + self, + include_data: bool = False, + include_policy: bool = False, + include_ref: bool = False, + ) -> JsonDict: + result: JsonDict = { + 'metadata': self.metadata.to_json(), + } + if include_ref: + result['ref'] = _ref_json(self.ref) + if include_data: + result['data'] = self.data + if include_policy: + result['policy'] = self.policy.to_json() + return result + + def to_store_json(self) -> JsonDict: + return { + 'format_version': SECRET_STORE_FORMAT_VERSION, + 'metadata': self.metadata.to_json(), + 'policy': self.policy.to_json(), + 'data': self.data, + } + + @staticmethod + def from_store_json(ref: SecretRef, payload: JsonDict) -> 'SecretRecord': + payload = _expect_object('SecretRecord', payload) + allowed = {'format_version', 'metadata', 'data', 'policy'} + _reject_unknown_keys('SecretRecord', payload, allowed) + _require_keys('SecretRecord', payload, allowed) + + format_version = payload['format_version'] + if ( + not isinstance(format_version, int) + or isinstance(format_version, bool) + or format_version != SECRET_STORE_FORMAT_VERSION + ): + raise CephSecretDataError( + f'unsupported SecretRecord.format_version: {format_version!r}' + ) + + metadata = _expect_object('SecretRecord.metadata', payload['metadata']) + data = _expect_str('SecretRecord.data', payload['data']) + policy = _expect_object('SecretRecord.policy', payload['policy']) + return SecretRecord( + ref=ref, + metadata=SecretMetadata.from_json(metadata), + data=data, + policy=SecretPolicy.from_json(policy), + ) + + +class SecretStoreMon(SecretStorageBackend): + """ + Mon KV-store backed secret store. + + Secret data keys: + secret_store/v1//// + + Stored payload shape: + { + "format_version": 1, + "metadata": {"version": 1, "created": "...", "updated": "..."}, + "policy": {"user_made": true, "editable": true} + "data": "", + } + + Per-namespace epoch keys (never touched by ls()): + secret_store/meta//_epoch + """ + + def __init__(self, mgr: Any) -> None: + self.mgr = mgr + + # ------------------------------------------------------------------ epoch + + def get_epoch(self, namespace: str) -> int: + """Return the current epoch for *namespace* (0 if never set).""" + raw = self.mgr.get_store(_epoch_key(namespace)) + if raw is None: + return 0 + try: + return int(str(raw)) + except Exception: + return 0 + + def bump_epoch(self, namespace: str) -> int: + """Increment and persist the epoch for *namespace*; return the new value. + + Best-effort: the mon KV store has no atomic read-modify-write, so there + is a theoretical TOCTOU window under concurrent mutations. This is + acceptable because the epoch is used only as a cheap change-detector, + not as a consistency guarantee. + """ + epoch = self.get_epoch(namespace) + 1 + self.mgr.set_store(_epoch_key(namespace), str(epoch)) + return epoch + + # ------------------------------------------------------------------ helpers + + def _kv_key_from_ref(self, ref: SecretRef) -> str: + if ref.scope == SecretScope.CUSTOM: + return f'{SECRET_STORE_PREFIX}{ref.namespace}/{ref.scope.value}/{ref.name}' + + if ref.scope == SecretScope.GLOBAL: + return f'{SECRET_STORE_PREFIX}{ref.namespace}/{ref.scope.value}/{ref.name}' + + return ( + f'{SECRET_STORE_PREFIX}{ref.namespace}/{ref.scope.value}/' + f'{ref.target}/{ref.name}' + ) + + # ------------------------------------------------------------------ CRUD + + def get(self, namespace: str, scope: SecretScope, target: str, name: str) -> Optional[SecretRecord]: + ref = _checked_ref(namespace, scope, target, name) + k = self._kv_key_from_ref(ref) + raw = self.mgr.get_store(k) + if raw is None: + return None + try: + payload = json.loads(raw) + if not isinstance(payload, dict): + raise CephSecretDataError( + f'expected a JSON object, got {type(payload).__name__}' + ) + return SecretRecord.from_store_json(ref, payload) + except CephSecretDataError: + raise + except Exception as e: + msg = f"Corrupted secret entry at {k}: {e}" + logger.warning(msg) + raise CephSecretDataError(msg) from e + + def set(self, + namespace: str, + scope: SecretScope, + target: str, + name: str, + data: SecretData, + user_made: bool = True, + editable: bool = True) -> SecretRecord: + + if not isinstance(data, str): + raise CephSecretException('Secret data must be a string') + if data == '': + raise CephSecretException('Secret data must not be empty') + + ref = _checked_ref(namespace, scope, target, name) + existing = None + try: + existing = self.get(ref.namespace, ref.scope, ref.target, ref.name) + if existing and not existing.policy.editable: + raise CephSecretException(f'Secret {ref.to_uri()} is not editable') + except CephSecretDataError as e: + raise CephSecretException(f'Secret {ref.to_uri()} is corrupted.') from e + + now = _now_iso() + if existing: + metadata = SecretMetadata( + version=existing.metadata.version + 1, + # Preserve the original creation timestamp across updates. + created=existing.metadata.created or now, + updated=now, + ) + else: + metadata = SecretMetadata(version=1, created=now, updated=now) + + rec = SecretRecord( + ref=ref, + metadata=metadata, + policy=SecretPolicy(user_made=user_made, editable=editable), + data=data, + ) + k = self._kv_key_from_ref(ref) + self.mgr.set_store(k, json.dumps(rec.to_store_json(), sort_keys=True)) + + # Bump epoch after a successful write so consumers can detect any change + # in this namespace without enumerating all secrets. + self.bump_epoch(ref.namespace) + return rec + + def rm( + self, + namespace: str, + scope: SecretScope, + target: str, + name: str, + ) -> bool: + ref = _checked_ref(namespace, scope, target, name) + k = self._kv_key_from_ref(ref) + existed = self.mgr.get_store(k) is not None + self.mgr.set_store(k, None) + if existed: + self.bump_epoch(ref.namespace) + return existed + + def ls( + self, + namespace: Optional[str] = None, + scope: Optional[SecretScope] = None, + target: Optional[str] = None, + ) -> List[SecretRecord]: + """ + List secrets matching the given filters. + + Corrupt/malformed module-owned entries are data errors, not part of the + normal listing contract, so they are raised as CephSecretDataError. + """ + if namespace is not None: + _checked_namespace(namespace) + if scope is not None and not isinstance(scope, SecretScope): + scope = SecretScope.from_str(str(scope)) + + # list by prefix for efficiency + prefix = SECRET_STORE_PREFIX + if namespace: + prefix += f'{namespace}/' + if scope: + prefix += f'{scope.value}/' + if target and scope not in (SecretScope.GLOBAL, SecretScope.CUSTOM): + prefix += f'{target}/' + items = self.mgr.get_store_prefix(prefix) or {} + records: List[SecretRecord] = [] + + for k, v in items.items(): + # k is full key: secret_store/v1/ns/scope/target/name + suffix = k[len(SECRET_STORE_PREFIX):] + parts = suffix.split('/') + + # reject keys with empty segments (double slash, trailing slash, etc.) + if '' in parts: + raise CephSecretDataError(f'{k}: empty path component in key') + + if len(parts) < 3: + raise CephSecretDataError(f'{k}: unexpected key structure') + + ns, sc = parts[0], parts[1] + + try: + sc_enum = SecretScope.from_str(sc) + except Exception as e: + raise CephSecretDataError(f'{k}: invalid scope {sc!r}: {e}') from e + + if sc_enum == SecretScope.CUSTOM: + tgt = '' + name = '/'.join(parts[2:]) + elif sc_enum == SecretScope.GLOBAL: + if len(parts) != 3: + raise CephSecretDataError(f'{k}: unexpected global key structure') + tgt = '' + name = parts[2] + else: + if len(parts) != 4: + raise CephSecretDataError( + f'{k}: unexpected targeted-scope key structure' + ) + tgt = parts[2] + name = parts[3] + + try: + ref = SecretRef(ns, sc_enum, tgt, name) + except ValueError as e: + raise CephSecretDataError(f'{k}: invalid secret key: {e}') from e + + # apply caller filters + if namespace and ref.namespace != namespace: + continue + if scope and ref.scope != scope: + continue + if target and ref.target != target: + continue + + # parse payload + try: + payload = json.loads(v) + except Exception as e: + raise CephSecretDataError(f'{k}: invalid JSON payload: {e}') from e + + if not isinstance(payload, dict): + raise CephSecretDataError( + f'{k}: payload is not a JSON object (got {type(payload).__name__})' + ) + + try: + records.append(SecretRecord.from_store_json(ref, payload)) + except CephSecretDataError as e: + raise CephSecretDataError(f'{k}: {e}') from e + except Exception as e: + raise CephSecretDataError(f'{k}: corrupted secret record: {e}') from e + + records.sort(key=lambda r: (r.ref.namespace, r.ref.scope.value, r.ref.target, r.ref.name)) + return records From 9c220eec0eac3bec950116b1ee0d8ce8d2b3a80a Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 26 Jan 2026 14:46:39 +0100 Subject: [PATCH 180/596] mgr/ceph_secrets: add SecretMgr for secrets handling and resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SecretMgr to encapsulate higher-level behavior on top of SecretStoreMon: listing helpers, scan_refs, scan_unresolved_refs, and resolve_object (walk nested dict/list structures). This keeps parsing/substitution logic out of the mgr module entrypoint and makes consumer behavior consistent. The module can now resolve secret://… references deterministically and provide structured scan output. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets/secret_mgr.py | 217 ++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets/secret_mgr.py diff --git a/src/pybind/mgr/ceph_secrets/secret_mgr.py b/src/pybind/mgr/ceph_secrets/secret_mgr.py new file mode 100644 index 00000000000..d957be59275 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/secret_mgr.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +import logging +from typing import Any, List, Optional, Set, Union, Hashable, Protocol + +from .secret_store import SecretRecord, SecretData +from ceph_secrets_types import ( + CephSecretException, + CephSecretNotFoundError, + SecretRef, + BadSecretURI, + SecretScope, + parse_secret_uri, + SECRET_SCHEME +) + + +_SECRET_URI_PREFIX = f'{SECRET_SCHEME}:/' + + +logger = logging.getLogger(__name__) + + +class SecretURI(Protocol, Hashable): + def to_uri(self) -> str: + ... + + +def _coerce_scope(scope: Union[SecretScope, str]) -> SecretScope: + if isinstance(scope, SecretScope): + return scope + return SecretScope.from_str(str(scope)) + + +class SecretMgr: + """ + Phase 1: Mon-store backend only. + + Secret data is an opaque string. Callers are responsible for any + structure within it (e.g. JSON-encoding a dict before storing and + decoding after retrieval). resolve_object() substitutes a secret URI + with the stored string directly. + """ + + def __init__(self, store: Any) -> None: + self.store = store + + def make_ref( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str = '', + name: str = '', + ) -> SecretRef: + try: + return SecretRef( + namespace=namespace, + scope=_coerce_scope(scope), + target=target or '', + name=name, + ) + except ValueError as e: + raise CephSecretException(str(e)) from e + + def get(self, ref: SecretRef) -> SecretRecord: + rec = self.store.get(ref.namespace, ref.scope, ref.target, ref.name) + if rec is None: + raise CephSecretNotFoundError(f"Secret not found: {ref.to_uri()}") + return rec + + def get_value(self, ref: SecretRef) -> str: + """Return the opaque data string for a secret.""" + rec = self.get(ref) + return rec.data + + def set( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + data: SecretData, + user_made: bool = True, + editable: bool = True, + ) -> SecretRecord: + if not isinstance(data, str): + raise CephSecretException('Secret data must be a string') + if data == '': + raise CephSecretException('Secret data must not be empty') + + ref = self.make_ref(namespace, scope, target, name) + return self.store.set( + ref.namespace, + ref.scope, + ref.target, + ref.name, + data, + user_made, + editable, + ) + + def rm( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + ) -> bool: + ref = self.make_ref(namespace, scope, target, name) + return self.store.rm(ref.namespace, ref.scope, ref.target, ref.name) + + def ls( + self, + namespace: Optional[str] = None, + scope: Optional[Union[SecretScope, str]] = None, + target: Optional[str] = None, + ) -> List[SecretRecord]: + sc = _coerce_scope(scope) if scope else None + return self.store.ls(namespace=namespace, scope=sc, target=target) + + def scan_unresolved_refs(self, obj: Any, namespace: str) -> Set[SecretURI]: + """ + Return secret refs found in `obj` that cannot be fetched. + """ + unresolved: Set[SecretURI] = set() + for ref in self.scan_refs(obj, namespace): + if isinstance(ref, SecretRef): + try: + self.get_value(ref) + except CephSecretException: + unresolved.add(ref) + else: + unresolved.add(ref) + return unresolved + + def scan_refs(self, obj: Any, namespace: str) -> Set[SecretURI]: + """Collect secret references from *obj*. + + A reference must be the *entire* (stripped) value of a string field — + e.g. ``"secret:/ns/global/pw"``. Embedded URIs inside a larger string + (e.g. ``"Bearer secret:/..."``) are not supported and are surfaced as + a BadSecretURI rather than silently ignored, so validation can flag + them before deploy. + """ + refs: Set[SecretURI] = set() + + def _scan(v: Any) -> None: + if isinstance(v, dict): + for vv in v.values(): + _scan(vv) + elif isinstance(v, (list, tuple)): + for vv in v: + _scan(vv) + elif isinstance(v, str): + s = v.strip() + if s.startswith(_SECRET_URI_PREFIX): + try: + refs.add(parse_secret_uri(s)) + except Exception as e: + logger.warning("Failed to parse secret uri %r: %s", s, e) + refs.add(BadSecretURI(raw=s, namespace=namespace, error=str(e))) + elif _SECRET_URI_PREFIX in s: + # Contains a secret URI but is not a whole-value reference. + err = ('embedded secret URIs are not supported; the entire ' + 'field value must be the secret URI') + logger.warning("Rejecting embedded secret uri in %r", v) + refs.add(BadSecretURI(raw=v, namespace=namespace, error=err)) + # otherwise: plain data, not a reference + + _scan(obj) + return refs + + def _resolve_secret_uri(self, uri: str) -> str: + """Resolve a single secret URI to its opaque data string.""" + try: + parsed_secret = parse_secret_uri(uri) + except CephSecretException as e: + raise CephSecretException(f"Invalid secret URI {uri!r}: {e}") from e + if not isinstance(parsed_secret, SecretRef): + raise CephSecretException(f"Invalid secret URI {uri!r}") + return self.get_value(parsed_secret) + + def _resolve(self, v: Any) -> Any: + """Recursively resolve secret URIs within a nested structure.""" + if isinstance(v, dict): + return {k: self._resolve(vv) for k, vv in v.items()} + if isinstance(v, list): + return [self._resolve(vv) for vv in v] + if isinstance(v, tuple): + return tuple(self._resolve(vv) for vv in v) + if isinstance(v, str): + s = v.strip() + if s.startswith(_SECRET_URI_PREFIX): + return self._resolve_secret_uri(s) + if _SECRET_URI_PREFIX in s: + # A field that embeds a secret URI inside other text would be + # deployed verbatim (leaking the unresolved placeholder). Fail + # loudly rather than silently passing it through. + raise CephSecretException( + f"Invalid secret reference {v!r}: embedded secret URIs are " + f"not supported; the entire field value must be the secret URI" + ) + return v + + def resolve_object(self, obj: Any) -> Any: + """Resolve secret references within nested dict/list/tuple structures. + + A string whose entire (stripped) value is a secret URI is replaced by + the stored opaque data string for that secret. Surrounding whitespace + around an otherwise-clean URI is tolerated; non-secret strings are + returned unchanged, preserving their original whitespace. + + A string that embeds a secret URI inside other text (e.g. + ``"Bearer secret:/..."``) is rejected with CephSecretException, since + partial substitution is not supported and silently emitting the literal + URI would leak an unresolved placeholder into deployed configuration. + """ + return self._resolve(obj) From 5cfab473be7f572ade239f3f009ed9ffe9fb0f4c Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Fri, 24 Oct 2025 16:30:16 +0530 Subject: [PATCH 181/596] mgr/cephadm: For updating NFS backends in HAProxy, send a SIGHUP signal to reload the configuration instead of restart Fixes: https://tracker.ceph.com/issues/73633 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 4 +- src/pybind/mgr/cephadm/serve.py | 20 ++++++---- .../mgr/cephadm/services/cephadmservice.py | 14 +++---- src/pybind/mgr/cephadm/services/ingress.py | 38 +++++++++++++------ src/pybind/mgr/cephadm/services/jaeger.py | 11 +++--- src/pybind/mgr/cephadm/services/monitoring.py | 6 +-- src/pybind/mgr/cephadm/services/nfs.py | 6 +-- src/pybind/mgr/cephadm/utils.py | 11 ++++++ 8 files changed, 71 insertions(+), 39 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 201b0319ba9..f5b96ff712b 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4909,8 +4909,8 @@ def _add_deploy_parser_args( ) parser_deploy.add_argument( '--send-signal-to-daemon', - action='store_true', - default=False, + type=str, + default=None, help='Send signal to daemon' ) diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 4f5d37250e2..2c8ef279db2 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1203,27 +1203,29 @@ class CephadmServe: else: # method uses new action enum type _scheduled_action = utils.Action.create(scheduled_action) - _action = svc_obj.choose_next_action( + _step = svc_obj.choose_next_action( _scheduled_action, dd.daemon_type, spec, curr_deps=deps, last_deps=last_deps, ) - if _action is not _scheduled_action: + if _step.action is not _scheduled_action: self.log.info( ( 'Daemon %s chose new action %s (was %s)' ' (deps: %r, last_deps: %r)' ), dd.name(), - _action, + _step.action, _scheduled_action, deps, last_deps, ) # convert back to legacy str type - action = str(_action) + action = str(_step.action) + skip_restart_for_reconfig = _step.skip_restart_for_reconfig + send_signal_to_daemon = _step.send_signal_to_daemon action = _ceph_service_next_action( action, dd.daemon_type, dd.name(), self.mgr, last_config ) @@ -1233,9 +1235,13 @@ class CephadmServe: action = 'redeploy' try: daemon_spec = CephadmDaemonDeploySpec.from_daemon_description(dd) - self.mgr._daemon_action(daemon_spec, action=action, - skip_restart_for_reconfig=skip_restart_for_reconfig, - send_signal_to_daemon=send_signal_to_daemon) + reconfig_extras: dict[str, Any] = {} + if skip_restart_for_reconfig: + reconfig_extras['skip_restart_for_reconfig'] = True + if send_signal_to_daemon: + reconfig_extras['send_signal_to_daemon'] = send_signal_to_daemon + self.mgr._daemon_action(daemon_spec, action=action, **reconfig_extras) + if self.mgr.cache.rm_scheduled_daemon_action(dd.hostname, dd.name()): self.mgr.cache.save_host(dd.hostname) except OrchestratorError as e: diff --git a/src/pybind/mgr/cephadm/services/cephadmservice.py b/src/pybind/mgr/cephadm/services/cephadmservice.py index c95d845aa8a..636eb0ddcc6 100644 --- a/src/pybind/mgr/cephadm/services/cephadmservice.py +++ b/src/pybind/mgr/cephadm/services/cephadmservice.py @@ -956,13 +956,13 @@ class CephadmService(metaclass=ABCMeta): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. """ if curr_deps == last_deps: - return scheduled_action + return utils.NextDaemonStep(scheduled_action) sym_diff = set(curr_deps).symmetric_difference(last_deps) logger.info( 'Reconfigure wanted %s: deps %r -> %r (diff %r)', @@ -971,7 +971,7 @@ class CephadmService(metaclass=ABCMeta): curr_deps, sym_diff, ) - return utils.Action.RECONFIG + return utils.NextDaemonStep(utils.Action.RECONFIG) class CephService(CephadmService): @@ -1970,7 +1970,7 @@ class CephExporterService(CephService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. @@ -2084,7 +2084,7 @@ def next_action_for_mgmt_stack_service( spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], -) -> utils.Action: +) -> utils.NextDaemonStep: """This function exists to help refactor existing code to use choose_next_action instead of if-blocks inside serve.py. It avoids the need to muck around with common base classes at the @@ -2092,7 +2092,7 @@ def next_action_for_mgmt_stack_service( Call this from choose_next_action. """ if curr_deps == last_deps: - return scheduled_action + return utils.NextDaemonStep(scheduled_action) sym_diff = set(curr_deps).symmetric_difference(last_deps) logger.info( 'Reconfigure wanted %s: deps %r -> %r (diff %r)', @@ -2123,4 +2123,4 @@ def next_action_for_mgmt_stack_service( # If so we ought to be able to vastly simplify this... if any(svc in e for e in sym_diff for svc in REDEPLOY_TRIGGERS): action = utils.Action.REDEPLOY - return action + return utils.NextDaemonStep(action) diff --git a/src/pybind/mgr/cephadm/services/ingress.py b/src/pybind/mgr/cephadm/services/ingress.py index 05d8525b9fd..d546d73bae9 100644 --- a/src/pybind/mgr/cephadm/services/ingress.py +++ b/src/pybind/mgr/cephadm/services/ingress.py @@ -555,14 +555,15 @@ class IngressService(CephService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. """ - action = super().choose_next_action( + step = super().choose_next_action( scheduled_action, daemon_type, spec, curr_deps, last_deps ) + action = step.action if ( action is not utils.Action.REDEPLOY and daemon_type == 'haproxy' @@ -570,13 +571,26 @@ class IngressService(CephService): and hasattr(spec, 'backend_service') ): backend_spec = self.mgr.spec_store[spec.backend_service].spec - if ( - backend_spec.service_type == 'nfs' - and self.has_placement_changed(last_deps, spec) - ): - logger.debug( - 'Redeploy wanted %s: placement has changed', - spec.service_name(), - ) - action = utils.Action.REDEPLOY - return action + if backend_spec.service_type == 'nfs': + if self.has_placement_changed(last_deps, spec): + logger.debug( + 'Redeploy wanted %s: placement has changed', + spec.service_name(), + ) + return utils.NextDaemonStep(utils.Action.REDEPLOY) + sym_diff = set(curr_deps).symmetric_difference(last_deps) + if sym_diff and all( + s.startswith(f'nfs.{backend_spec.service_id}') + for s in sym_diff + ): + logger.debug( + 'Reconfigure HAProxy with SIGHUP due to change in NFS backend ' + '(%s)', + spec.service_name(), + ) + return utils.NextDaemonStep( + utils.Action.RECONFIG, + skip_restart_for_reconfig=True, + send_signal_to_daemon='SIGHUP', + ) + return step diff --git a/src/pybind/mgr/cephadm/services/jaeger.py b/src/pybind/mgr/cephadm/services/jaeger.py index 1d60680cc0f..8d69c53cec8 100644 --- a/src/pybind/mgr/cephadm/services/jaeger.py +++ b/src/pybind/mgr/cephadm/services/jaeger.py @@ -1,3 +1,4 @@ +from dataclasses import replace from typing import List, cast, Optional, TYPE_CHECKING from cephadm.services.cephadmservice import CephadmService, CephadmDaemonDeploySpec from ceph.deployment.service_spec import TracingSpec, ServiceSpec @@ -57,20 +58,20 @@ class JaegerAgentService(CephadmService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. """ - action = super().choose_next_action( + step = super().choose_next_action( scheduled_action, daemon_type, spec, curr_deps, last_deps ) # changes to jaeger-agent deps affect the way the unit.run for # the daemon is written, which we rewrite on redeploy, but not # on reconfig. - if action is utils.Action.RECONFIG: - action = utils.Action.REDEPLOY - return action + if step.action is utils.Action.RECONFIG: + return replace(step, action=utils.Action.REDEPLOY) + return step @register_cephadm_service diff --git a/src/pybind/mgr/cephadm/services/monitoring.py b/src/pybind/mgr/cephadm/services/monitoring.py index 86f0aa0a8e8..71a19a7583d 100644 --- a/src/pybind/mgr/cephadm/services/monitoring.py +++ b/src/pybind/mgr/cephadm/services/monitoring.py @@ -485,7 +485,7 @@ class AlertmanagerService(CephadmService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. @@ -796,7 +796,7 @@ class PrometheusService(CephadmService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. @@ -863,7 +863,7 @@ class NodeExporterService(CephadmService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. diff --git a/src/pybind/mgr/cephadm/services/nfs.py b/src/pybind/mgr/cephadm/services/nfs.py index 28d5ec7bed9..45d7aad1b9c 100644 --- a/src/pybind/mgr/cephadm/services/nfs.py +++ b/src/pybind/mgr/cephadm/services/nfs.py @@ -578,13 +578,13 @@ class NFSService(CephService): spec: Optional[ServiceSpec], curr_deps: List[str], last_deps: List[str], - ) -> utils.Action: + ) -> utils.NextDaemonStep: """Given the scheduled_action, service spec, daemon_type, and current and previous dependency lists return the next action that this service would prefer cephadm take. """ if curr_deps == last_deps: - return scheduled_action + return utils.NextDaemonStep(scheduled_action) sym_diff = set(curr_deps).symmetric_difference(last_deps) logger.info( 'Reconfigure wanted %s: deps %r -> %r (diff %r)', @@ -598,4 +598,4 @@ class NFSService(CephService): only_kmip_updated = all(s.startswith('kmip') for s in sym_diff) if not only_kmip_updated: action = utils.Action.REDEPLOY - return action + return utils.NextDaemonStep(action) diff --git a/src/pybind/mgr/cephadm/utils.py b/src/pybind/mgr/cephadm/utils.py index 8221b950af4..92725badc55 100644 --- a/src/pybind/mgr/cephadm/utils.py +++ b/src/pybind/mgr/cephadm/utils.py @@ -1,6 +1,7 @@ import logging import json import socket +from dataclasses import dataclass from enum import Enum from functools import wraps from typing import ( @@ -92,6 +93,16 @@ class Action(str, Enum): return self.value +@dataclass(frozen=True) +class NextDaemonStep: + """Result of CephadmService.choose_next_action: high-level action plus + optional reconfig hints (e.g. HAProxy reload via signal instead of restart). + """ + action: Action + skip_restart_for_reconfig: bool = False + send_signal_to_daemon: Optional[str] = None + + def name_to_config_section(name: str) -> ConfEntity: """ Map from daemon names to ceph entity names (as seen in config) From 349c12c6d0ca67157e75bc6cba63de525b9b1e97 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 26 Jan 2026 15:14:35 +0100 Subject: [PATCH 182/596] mgr/ceph_secrets: add 'ceph secret' CLI commands and input parsing This commit has the following changes: 1) Add the ceph_secrets mgr module entrypoint and wire it to SecretMgr. Implement the core RPC surface consumed by other mgr modules (secret_ls/get/set/rm, secret_get_value, secret_get_version) and keep the implementation focused on the internal API. 2) Add user-facing CLI commands (ceph secret ls/get/set/rm) using parse_secret_path. Secret data is accepted via -i (inbuf) only for script-friendly usage. Add secret get-value for plain-string output without a JSON envelope. Ensure consistent JSON output and error mapping to EINVAL/ENOENT, while preserving safe non-reveal defaults unless explicitly requested. 3) Add the scanning and resolution helpers (scan_refs, scan_unresolved_refs, resolve_object) through the ceph_secrets module RPC API. This lets consumers reliably detect secret:/... references and resolve them inside nested objects without duplicating logic. The behavior is delegated to SecretMgr to keep parsing/resolution consistent across the stack. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets/__init__.py | 2 + src/pybind/mgr/ceph_secrets/cli.py | 3 + src/pybind/mgr/ceph_secrets/module.py | 365 ++++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets/__init__.py create mode 100644 src/pybind/mgr/ceph_secrets/cli.py create mode 100644 src/pybind/mgr/ceph_secrets/module.py diff --git a/src/pybind/mgr/ceph_secrets/__init__.py b/src/pybind/mgr/ceph_secrets/__init__.py new file mode 100644 index 00000000000..ee85dc9d376 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .module import Module diff --git a/src/pybind/mgr/ceph_secrets/cli.py b/src/pybind/mgr/ceph_secrets/cli.py new file mode 100644 index 00000000000..eef1820f112 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/cli.py @@ -0,0 +1,3 @@ +from mgr_module import CLICommandBase + +CephSecretsCLICommand = CLICommandBase.make_registry_subtype("CephSecretsCLICommand") diff --git a/src/pybind/mgr/ceph_secrets/module.py b/src/pybind/mgr/ceph_secrets/module.py new file mode 100644 index 00000000000..5e5e5b22db7 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/module.py @@ -0,0 +1,365 @@ +# -*- coding: utf-8 -*- +import functools +from typing import Any, Dict, List, Optional, Callable, Tuple, TypeVar, Union +import errno + +from .cli import CephSecretsCLICommand +from object_format import ObjectFormatAdapter, ErrorResponse, Responder +from mgr_module import ( + MgrModule, + Option +) +from .secret_mgr import SecretMgr +from ceph_secrets_types import ( + CephSecretException, + CephSecretDataError, + CephSecretNotFoundError, + SecretRef, + SecretScope, + parse_secret_path, + parse_secret_uri, +) +from .backends import BACKENDS + + +_T = TypeVar('_T') + + +def _handle_secret_errors(fn: Callable[..., _T]) -> Callable[..., _T]: + """Decorator for CLI handlers: converts CephSecretException into + ErrorResponse so that Responder / ErrorResponseHandler can catch it.""" + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> _T: + try: + return fn(*args, **kwargs) + except CephSecretException as e: + raise ErrorResponse(str(e)) from e + return wrapper + + +class Module(MgrModule): + """Standalone secrets mgr module. + + This module owns the mgr KV-store entries for secrets (namespace: mgr/secrets) + and provides both: + - RPC methods for other mgr modules via `remote(...)` + - CLI commands: `ceph secret ...` + + Storage keys inside the mgr KV store: + secret data: secret_store/v1///... + epoch (meta): secret_store/meta//_epoch + + Epoch is per-namespace: a mutation in namespace A does not affect the epoch + of namespace B, so consumers only see changes relevant to their namespace. + Epoch logic lives entirely in the store backend so future backends + (e.g. Vault) can implement it natively. + + Method organisation: + - Public methods (no leading underscore): RPC surface called via + mgr.remote(). Accept individual kwargs for wire-format compatibility. + Each delegates immediately to the corresponding _secret_* method. + - Private _secret_* methods: real implementations, operate on SecretRef. + Called directly by CLI handlers and internal code. + """ + CLICommand = CephSecretsCLICommand + + MODULE_OPTIONS = [ + Option( + 'secrets_backend', + type='str', + default='mon', + desc='Secrets storage backend. Currently only "mon" (Mon KV store) is supported.', + ), + ] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + backend_name: str = str(self.get_module_option('secrets_backend')) + try: + backend_cls = BACKENDS[backend_name] + except KeyError as e: + raise RuntimeError( + f"Unsupported secrets backend: {backend_name}" + ) from e + try: + self.secret_mgr = SecretMgr(backend_cls(self)) + except Exception as e: + raise RuntimeError( + f"Failed to initialize secrets backend '{backend_name}': {e}" + ) from e + + # ------------------------------------------------------------------ epoch + + def secret_get_epoch(self, namespace: str) -> int: + """Return the current epoch for *namespace*. + + Consumers (e.g., cephadm) can use this as a cheap change-detector: + if the epoch hasn't changed since the last check, no secrets in this + namespace have been mutated. + """ + return self.secret_mgr.store.get_epoch(namespace) + + # ------------------------------------------------------------------ RPC surface + + def secret_ls( + self, + namespace: Optional[str] = None, + scope: Optional[str] = None, + target: Optional[str] = None, + show_values: bool = False, + show_internals: bool = False, + ) -> Dict[str, Any]: + sc = SecretScope.from_str(scope) if scope else None + records = self.secret_mgr.ls(namespace=namespace, scope=sc, target=target) + out: Dict[str, Any] = {} + for r in records: + if r.ref.target: + key = f'{r.ref.namespace}/{r.ref.scope.value}/{r.ref.target}/{r.ref.name}' + else: + key = f'{r.ref.namespace}/{r.ref.scope.value}/{r.ref.name}' + out[key] = r.to_public_json( + include_data=bool(show_values), + include_policy=show_internals, + include_ref=True, + ) + return out + + def secret_get( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + reveal: bool = False, + ) -> Optional[Dict[str, Any]]: + """RPC surface — called via mgr.remote(). Internal code uses _secret_get().""" + return self._secret_get( + self.secret_mgr.make_ref(namespace, scope, target, name), + reveal=reveal, + ) + + def secret_get_value( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + ) -> Optional[str]: + """RPC surface — return the raw secret data string, or None if not found. + + Called via mgr.remote(). Internal code uses _secret_get_value(). + """ + return self._secret_get_value( + self.secret_mgr.make_ref(namespace, scope, target, name) + ) + + def secret_get_version( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + ) -> Optional[int]: + """RPC surface — called via mgr.remote(). Internal code uses _secret_get_version().""" + return self._secret_get_version( + self.secret_mgr.make_ref(namespace, scope, target, name) + ) + + def secret_get_versions(self, uris: List[str]) -> Dict[str, Optional[int]]: + """Batch-fetch version numbers for a list of secret URIs. + + Each entry in *uris* must be a canonical ``secret:/...`` URI, such as + one returned by ``SecretRef.to_uri()``. URIs that cannot be parsed are + skipped and logged at ERROR level; a missing key in the result indicates + malformed input rather than a not-found secret. + + Note that ``scan_refs()`` may also return malformed or embedded + secret-like strings for validation/reporting. Callers should pass only + canonical ``secret:/...`` URIs to this method. + + Returns a dict keyed by the input URI mapping to the current version + integer, or ``None`` if the secret does not exist. + """ + out: Dict[str, Optional[int]] = {} + for uri in uris or []: + try: + ref = parse_secret_uri(uri) + except CephSecretException: + self.log.error( + "secret_get_versions: skipping invalid URI %r", + uri, exc_info=True + ) + continue + out[uri] = self._secret_get_version(ref) # CephSecretDataError propagates + return out + + def secret_set( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + data: str, + user_made: bool = True, + editable: bool = True, + ) -> Dict[str, Any]: + """RPC surface — called via mgr.remote(). Internal code uses _secret_set().""" + return self._secret_set( + self.secret_mgr.make_ref(namespace, scope, target, name), + data=data, + user_made=user_made, + editable=editable, + ) + + def secret_rm( + self, + namespace: str, + scope: Union[SecretScope, str], + target: str, + name: str, + ) -> bool: + """RPC surface — called via mgr.remote(). Internal code uses _secret_rm().""" + return self._secret_rm(self.secret_mgr.make_ref(namespace, scope, target, name)) + + def resolve_object(self, obj: Any) -> Any: + return self.secret_mgr.resolve_object(obj) + + def scan_refs(self, obj: Any, namespace: str) -> List[str]: + return sorted({u.to_uri() for u in + self.secret_mgr.scan_refs(obj, namespace)}) + + def scan_unresolved_refs(self, obj: Any, namespace: str) -> List[str]: + return sorted({u.to_uri() for u in + self.secret_mgr.scan_unresolved_refs(obj, namespace)}) + + # ------------------------------------------------------------------ ref-based implementations + + def _secret_get(self, ref: SecretRef, reveal: bool = False) -> Optional[Dict[str, Any]]: + try: + rec = self.secret_mgr.get(ref) + except CephSecretDataError: + # Corruption is not the same as absence; let callers/CLI report a + # data error instead of returning the same sentinel as "not found". + raise + except CephSecretNotFoundError: + return None + return rec.to_public_json(include_data=reveal, include_policy=False, include_ref=False) + + def _secret_get_value(self, ref: SecretRef) -> Optional[str]: + """Return the raw data string for a secret, or None if not found.""" + try: + rec = self.secret_mgr.get(ref) + except CephSecretDataError: + raise + except CephSecretNotFoundError: + return None + return rec.data + + def _secret_get_version(self, ref: SecretRef) -> Optional[int]: + try: + rec = self.secret_mgr.get(ref) + except CephSecretDataError: + raise + except CephSecretNotFoundError: + return None + return rec.metadata.version + + def _secret_set( + self, + ref: SecretRef, + data: str, + user_made: bool = True, + editable: bool = True, + ) -> Dict[str, Any]: + rec = self.secret_mgr.set( + namespace=ref.namespace, + scope=ref.scope, + target=ref.target, + name=ref.name, + data=data, + user_made=user_made, + editable=editable, + ) + return rec.to_public_json(include_data=False, include_policy=False, include_ref=False) + + def _secret_rm(self, ref: SecretRef) -> bool: + return self.secret_mgr.rm(ref.namespace, ref.scope, ref.target, ref.name) + + # ------------------------------------------------------------------ CLI commands + + @CephSecretsCLICommand.Read('secret ls') + @Responder(functools.partial(ObjectFormatAdapter, compatible=True)) + @_handle_secret_errors + def _cli_secret_ls( + self, + namespace: Optional[str] = None, + scope: Optional[str] = None, + sec_target: Optional[str] = None, + reveal: bool = False, + show_internals: bool = False, + ) -> Dict[str, Any]: + return self.secret_ls( + namespace=namespace, + scope=scope, + target=sec_target, + show_values=reveal, + show_internals=show_internals, + ) + + @CephSecretsCLICommand.Read('secret get') + @Responder(functools.partial(ObjectFormatAdapter, compatible=True)) + @_handle_secret_errors + def _cli_secret_get_by_path( + self, + path: str, + reveal: bool = False, + ) -> Dict[str, Any]: + ref = parse_secret_path(path) + res = self._secret_get(ref, reveal=reveal) + if res is None: + raise ErrorResponse('secret error: not found', return_value=-errno.ENOENT) + return res + + @CephSecretsCLICommand.Read('secret get-value') + @_handle_secret_errors + def _cli_secret_get_value_by_path( + self, + path: str, + ) -> Tuple[int, str, str]: + """Return the raw secret data string for the given path. + + Unlike ``secret get --reveal``, this command outputs the secret value + directly as a plain string with no JSON envelope, making it suitable + for use in shell scripts and pipelines. + """ + ref = parse_secret_path(path) + value = self._secret_get_value(ref) + if value is None: + raise ErrorResponse('secret error: not found', return_value=-errno.ENOENT) + return 0, value, '' + + @CephSecretsCLICommand.Write('secret set') + @Responder(functools.partial(ObjectFormatAdapter, compatible=True)) + @_handle_secret_errors + def _cli_secret_set_by_path( + self, + path: str, + inbuf: Optional[str] = None, + ) -> Dict[str, Any]: + if inbuf is None: + raise ErrorResponse('secret error: use -i to provide secret data') + if inbuf == '': + raise ErrorResponse('secret error: secret data must not be empty') + ref = parse_secret_path(path) + return self._secret_set(ref, data=inbuf) + + @CephSecretsCLICommand.Write('secret rm') + @Responder(functools.partial(ObjectFormatAdapter, compatible=True)) + @_handle_secret_errors + def _cli_secret_rm_by_path( + self, + path: str, + ) -> Dict[str, Any]: + ref = parse_secret_path(path) + existed = self._secret_rm(ref) + return {'status': 'removed' if existed else 'not_found'} From e737f8b73fed73980528d1cd71211e5d71d9778b Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 26 Jan 2026 14:51:44 +0100 Subject: [PATCH 183/596] mgr: add CephSecretsClient wrapper for ceph_secrets RPC Add a thin typed client around mgr.remote() for consuming the ceph_secrets module. Exposes get/set/rm, epoch and version queries, batch version fetch, scan and resolve helpers. Lives alongside ceph_secrets_types.py so any mgr module can import it without depending on the ceph_secrets package directly. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets_client.py | 363 ++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 src/pybind/mgr/ceph_secrets_client.py diff --git a/src/pybind/mgr/ceph_secrets_client.py b/src/pybind/mgr/ceph_secrets_client.py new file mode 100644 index 00000000000..2386de71043 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets_client.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Protocol, Union + +from ceph_secrets_types import SecretScope + +logger = logging.getLogger(__name__) + +ScopeArg = Union[SecretScope, str] + + +class MgrRemote(Protocol): + """Minimal interface required from the mgr object.""" + def remote(self, module: str, method: str, **kwargs: Any) -> Any: + ... + + +class CephSecretsClient: + """Thin client for calling the ceph_secrets mgr module via mgr.remote(). + + This file lives in src/pybind/mgr/ alongside ceph_secrets_types.py so + any mgr module can import it without depending on the ceph_secrets module + directory directly. + + All methods translate to a single mgr.remote() call and raise RuntimeError + if the ceph_secrets module is unreachable (e.g. not enabled). + + Typical usage:: + + client = CephSecretsClient(self) # self is a MgrModule instance + rec = client.secret_get("cephadm", SecretScope.HOST, "node1", "ssh_key") + if rec: + version = rec["metadata"]["version"] + """ + + DEFAULT_MODULE = "ceph_secrets" + + def __init__(self, mgr: MgrRemote, module: str = DEFAULT_MODULE) -> None: + self.mgr = mgr + self.module = module + + def _remote(self, method: str, **kwargs: Any) -> Any: + try: + return self.mgr.remote(self.module, method, **kwargs) + except Exception as e: + raise RuntimeError( + f"Cannot call secrets mgr-module '{self.module}' (is it enabled?) {e}" + ) from e + + # ---- epoch ---- + + def secret_get_epoch(self, namespace: str) -> int: + """Return the current mutation epoch for *namespace*. + + The epoch is a monotonically increasing integer that is incremented on + every successful set and on rm only when an existing secret is actually + removed (an idempotent rm returning not-found does not bump the epoch). It is + deliberately per-namespace: a mutation in namespace A does not change + the epoch of namespace B. + + Use this as a cheap change-detector: cache the epoch value after your + last sync; if it is unchanged on the next poll, no secrets in this + namespace have been mutated and you can skip a full refresh. + + Args: + namespace: The secret namespace to query (e.g. ``"cephadm"``). + + Returns: + The current epoch as a non-negative integer. Starts at 0 for a + namespace that has never been written to. + """ + return self._remote("secret_get_epoch", namespace=namespace) + + # ---- module API wrappers ---- + + def secret_get( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + reveal: bool = False, + ) -> Optional[Dict[str, Any]]: + """Retrieve a secret record by its full address. + + Returns the secret's metadata and, if *reveal* is True, its data + payload. Returns ``None`` if the secret does not exist; raises + RuntimeError if the module is unreachable. + + The returned dict contains a ``metadata`` object with fields such as ``version``, + ``created``, and ``updated``, etc. It intentionally does not include a + ``ref`` object because the caller already supplied the identity. + The ``data`` key is only present when *reveal* is True. + + Args: + namespace: The secret namespace (e.g. ``"cephadm"``). + scope: The secret scope — a :class:`SecretScope` value or its + string equivalent (``"global"``, ``"service"``, + ``"host"``, ``"custom"``). + target: The scope target. Must be non-empty for ``service`` and + ``host`` scopes; must be empty for ``global`` and + ``custom``. + name: The secret name or, for ``custom`` scope, the + slash-delimited path (e.g. ``"app/db/password"``). + reveal: If True, include the secret's data payload in the + response. Defaults to False to avoid accidental + exposure in logs. + + Returns: + A dict of the form ``{"metadata": {...}}`` plus optional ``data``, + or ``None`` if not found. + """ + return self._remote( + "secret_get", + namespace=namespace, + scope=scope, + target=target, + name=name, + reveal=reveal, + ) + + def secret_get_value( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> Optional[str]: + """Return the raw secret data string. + + Returns the stored opaque string directly, without any JSON envelope + or metadata. Returns ``None`` if the secret does not exist. + + Use this when you need the secret value itself — for example, to pass + a password to a subprocess or to resolve a credential at deploy time. + For metadata inspection or change-detection, use :meth:`secret_get` or + :meth:`secret_get_version` instead. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + The stored string, or ``None`` if the secret does not exist. + """ + return self._remote( + "secret_get_value", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + + def secret_get_version( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> Optional[int]: + """Return the current version number of a secret. + + A convenience wrapper around :meth:`secret_get` for callers that only + need to check whether a secret has changed since they last read it, + without fetching its payload. + + The version is incremented on every successful :meth:`secret_set` call + for the same address. The first write produces version 1. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + The current version as a positive integer, or ``None`` if the + secret does not exist. + """ + return self._remote( + "secret_get_version", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + + def secret_get_versions(self, uris: List[str]) -> Dict[str, Optional[int]]: + """Batch-fetch version numbers for a list of secret URIs. + + More efficient than calling :meth:`secret_get_version` in a loop when + you need to check many secrets at once (e.g. during a cephadm + reconciliation pass). + + Each entry in *uris* must be a canonical ``secret:/...`` URI, such as + one returned by ``SecretRef.to_uri()``. URIs that cannot be parsed are + skipped and logged at ERROR level on the module side; a missing key in + the result indicates malformed input rather than a not-found secret. + + Note that :meth:`scan_refs` may also return malformed or embedded + secret-like strings for validation/reporting. Pass only canonical + ``secret:/...`` URIs to this method. + + Args: + uris: A list of canonical secret URIs. Example:: + + [ + "secret:/cephadm/host/node1/ssh_key", + "secret:/cephadm/global/dashboard_password", + ] + + Returns: + A dict keyed by the input URI mapping to the current version + integer, or ``None`` if the secret does not exist. + """ + return self._remote("secret_get_versions", uris=uris) + + def secret_set( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + data: str, + user_made: bool = True, + editable: bool = True, + ) -> Dict[str, Any]: + """Create or update a secret. + + If a secret at the given address already exists its data is replaced + and its version is incremented. If it does not exist it is created at + version 1. The ``created`` timestamp is set on first write and never + changed thereafter; ``updated`` is refreshed on every write. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + data: The secret payload as an opaque string. Callers are + responsible for any structure within it (e.g. + JSON-encoding a dict before storing and decoding after + retrieval). + user_made: Whether this secret was created by a human operator + rather than automatically by a Ceph component. Defaults + to True; set to False for programmatically generated + secrets. + editable: Whether the secret may be updated or removed by + automated tooling. Defaults to True. + + Returns: + A dict containing the written record's ``metadata`` object. The + response intentionally omits ``ref`` and never includes the data + payload. + """ + return self._remote( + "secret_set", + namespace=namespace, + scope=scope, + target=target, + name=name, + data=data, + user_made=user_made, + editable=editable, + ) + + def secret_rm( + self, + namespace: str, + scope: ScopeArg, + target: str, + name: str, + ) -> bool: + """Remove a secret. + + Idempotent: returns False if the secret did not exist rather than + raising. Raises RuntimeError only if the module is unreachable. + + Args: + namespace: The secret namespace. + scope: The secret scope. + target: The scope target (empty for ``global`` and ``custom``). + name: The secret name or custom path. + + Returns: + True if the secret existed and was removed; False if it was not + found. + """ + return bool( + self._remote( + "secret_rm", + namespace=namespace, + scope=scope, + target=target, + name=name, + ) + ) + + def scan_unresolved_refs(self, obj: Any, namespace: str) -> Any: + """Return all unresolved secret URI references found in *obj*. + + Walks *obj* recursively (dicts, lists, strings) and collects every + secret-like reference that cannot currently be resolved because it is + missing, malformed, embedded inside a larger string, or cannot be read + successfully. + + Useful for validation: call this before deploying a configuration + object to detect missing or invalid secret references early. + + Args: + obj: The object to scan. May be a dict, list, or any + JSON-like structure. + namespace: The namespace context used while scanning/reporting + references. + + Returns: + A collection of unresolved reference strings found in *obj*. + """ + return self._remote("scan_unresolved_refs", obj=obj, namespace=namespace) + + def scan_refs(self, obj: Any, namespace: str) -> Any: + """Return all secret URI references found in *obj*. + + Like :meth:`scan_unresolved_refs` but returns every whole-value secret + URI and every malformed or embedded secret-like reference found while + scanning. Malformed or embedded entries are returned as their raw string + value so callers can report them. + + Useful for auditing which secrets a configuration object depends on and + for surfacing malformed or embedded references before deployment. + + Args: + obj: The object to scan. + namespace: The namespace context for the scan. + + Returns: + A collection of all secret URI strings found in *obj*. + """ + return self._remote("scan_refs", obj=obj, namespace=namespace) + + def resolve_object(self, obj: Any) -> Any: + """Resolve all secret URI references in *obj*. + + Walks *obj* recursively and replaces every whole-value ``secret:/...`` + URI string with the stored opaque string for the referenced secret. + Surrounding whitespace around a URI reference is ignored, but embedding + a secret URI inside a larger string is rejected because partial + substitution is not supported. + + Args: + obj: The object to resolve. May be a dict, list, or any JSON-like + structure containing ``secret:/...`` URI strings. + + Returns: + A resolved copy of *obj* with all secret URIs replaced by their stored opaque strings. + Raises RuntimeError if any referenced secret cannot be resolved + or if the module is unreachable. + """ + return self._remote("resolve_object", obj=obj) From 92981c7687b4959e138b5a8c02fa72d43e2e9294 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Thu, 21 May 2026 15:46:15 +0200 Subject: [PATCH 184/596] mgr/ceph_secrets: add ceph_secrets module to tox.ini Adds ceph_secrets to tox.ini mypy and flake8 targets. Signed-off-by: Redouane Kachach --- src/pybind/mgr/tox.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pybind/mgr/tox.ini b/src/pybind/mgr/tox.ini index 46892ade74e..647b9e45a93 100644 --- a/src/pybind/mgr/tox.ini +++ b/src/pybind/mgr/tox.ini @@ -112,6 +112,7 @@ commands = -m alerts \ -m balancer \ -m cephadm \ + -m ceph_secrets \ -m crash \ -m dashboard \ -m devicehealth \ @@ -166,6 +167,9 @@ modules = alerts \ balancer \ cephadm \ + ceph_secrets \ + ceph_secrets_client.py \ + ceph_secrets_types.py \ cli_api \ crash \ devicehealth \ From 431fac7aded8abb60f77fd272b11afc85137419f Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Thu, 11 Jun 2026 10:55:43 +0200 Subject: [PATCH 185/596] mgr/ceph_secrets: add unit tests for all modules Add pytest coverage for the full stack: secret types and URI/path parsing, storage backend contract, Mon KV store (CRUD, epoch, serialization, corruption handling), SecretMgr (scan/resolve), module RPC surface and CLI handlers, and the CephSecretsClient wrapper. Gate test imports on the UNITTEST env var following the SMB module pattern. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- src/pybind/mgr/ceph_secrets/__init__.py | 8 +- src/pybind/mgr/ceph_secrets/tests/__init__.py | 0 src/pybind/mgr/ceph_secrets/tests/conftest.py | 20 + .../mgr/ceph_secrets/tests/test_backends.py | 23 + .../mgr/ceph_secrets/tests/test_module.py | 424 ++++++++++++++ .../ceph_secrets/tests/test_secret_backend.py | 80 +++ .../mgr/ceph_secrets/tests/test_secret_mgr.py | 339 ++++++++++++ .../ceph_secrets/tests/test_secret_store.py | 516 ++++++++++++++++++ src/pybind/mgr/ceph_secrets_client.py | 5 +- src/pybind/mgr/ceph_secrets_types.py | 10 + .../mgr/tests/test_ceph_secrets_client.py | 317 +++++++++++ .../mgr/tests/test_ceph_secrets_types.py | 487 +++++++++++++++++ 12 files changed, 2226 insertions(+), 3 deletions(-) create mode 100644 src/pybind/mgr/ceph_secrets/tests/__init__.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/conftest.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/test_backends.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/test_module.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/test_secret_backend.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/test_secret_mgr.py create mode 100644 src/pybind/mgr/ceph_secrets/tests/test_secret_store.py create mode 100644 src/pybind/mgr/tests/test_ceph_secrets_client.py create mode 100644 src/pybind/mgr/tests/test_ceph_secrets_types.py diff --git a/src/pybind/mgr/ceph_secrets/__init__.py b/src/pybind/mgr/ceph_secrets/__init__.py index ee85dc9d376..b02a13abff1 100644 --- a/src/pybind/mgr/ceph_secrets/__init__.py +++ b/src/pybind/mgr/ceph_secrets/__init__.py @@ -1,2 +1,8 @@ -# flake8: noqa +import os + +if 'UNITTEST' in os.environ: + import tests # noqa: F401 + from .module import Module + +__all__ = ['Module'] diff --git a/src/pybind/mgr/ceph_secrets/tests/__init__.py b/src/pybind/mgr/ceph_secrets/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/ceph_secrets/tests/conftest.py b/src/pybind/mgr/ceph_secrets/tests/conftest.py new file mode 100644 index 00000000000..3625d3edc9b --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/conftest.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +import pytest +from mgr_module import MgrModule + + +@pytest.fixture +def mgr() -> MgrModule: + return MgrModule.__new__(MgrModule) + + +@pytest.fixture +def store(mgr: MgrModule): + from ceph_secrets.secret_store import SecretStoreMon + return SecretStoreMon(mgr) + + +@pytest.fixture +def secret_mgr(store): + from ceph_secrets.secret_mgr import SecretMgr + return SecretMgr(store) diff --git a/src/pybind/mgr/ceph_secrets/tests/test_backends.py b/src/pybind/mgr/ceph_secrets/tests/test_backends.py new file mode 100644 index 00000000000..8f40f475066 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/test_backends.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Tests for ceph_secrets.backends (registry of storage backends).""" +from __future__ import annotations + +from ceph_secrets.backends import BACKENDS +from ceph_secrets.secret_store import SecretStoreMon + + +class TestBackendsRegistry: + def test_mon_backend_registered(self): + assert "mon" in BACKENDS + + def test_mon_maps_to_correct_class(self): + assert BACKENDS["mon"] is SecretStoreMon + + def test_only_known_backends(self): + # Update this test when new backends are added. + assert set(BACKENDS.keys()) == {"mon"} + + def test_backend_is_instantiable(self, mgr): + cls = BACKENDS["mon"] + instance = cls(mgr) + assert isinstance(instance, SecretStoreMon) diff --git a/src/pybind/mgr/ceph_secrets/tests/test_module.py b/src/pybind/mgr/ceph_secrets/tests/test_module.py new file mode 100644 index 00000000000..8728ec8bb07 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/test_module.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +"""Tests for ceph_secrets.module.Module — RPC surface and internal methods.""" +from __future__ import annotations + +import json +import pytest +from typing import Any +from unittest.mock import MagicMock, patch + +from ceph_secrets.module import Module +from ceph_secrets.secret_store import SecretStoreMon +from ceph_secrets.secret_mgr import SecretMgr +from ceph_secrets_types import ( + SecretScope, + SecretRef, + CephSecretException, + CephSecretDataError, +) + + +# --------------------------------------------------------------------------- +# Helper: build a Module without going through MgrModule.__init__ +# --------------------------------------------------------------------------- + +def _make_module(mgr_stub) -> Module: + """Bypass MgrModule.__init__ and wire up manually.""" + mod = object.__new__(Module) + mod.secret_mgr = SecretMgr(SecretStoreMon(mgr_stub)) + return mod + + +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Module.__init__ — backend selection +# --------------------------------------------------------------------------- + +class TestModuleInit: + def test_default_backend_initialises_secret_mgr(self, mgr): + """Module.__init__ with default 'mon' backend wires up SecretMgr.""" + from mgr_module import MgrModule + mod = object.__new__(Module) + with patch.object(MgrModule, "__init__", lambda self, *a, **kw: None): + with patch.object(MgrModule, "get_module_option", return_value="mon"): + Module.__init__(mod, mgr) + assert isinstance(mod.secret_mgr, SecretMgr) + + def test_unsupported_backend_raises(self, mgr): + """Unknown backend name raises RuntimeError.""" + from mgr_module import MgrModule + with patch.object(MgrModule, "__init__", lambda self, *a, **kw: None): + with patch("ceph_secrets.module.BACKENDS", {}): + with patch.object(MgrModule, "get_module_option", return_value="vault"): + with pytest.raises(RuntimeError, match="Unsupported secrets backend"): + Module.__init__(object.__new__(Module), mgr) + + def test_backend_init_failure_raises(self, mgr): + """Backend constructor failure raises RuntimeError with helpful message.""" + from mgr_module import MgrModule + broken_cls = MagicMock(side_effect=Exception("connection refused")) + with patch.object(MgrModule, "__init__", lambda self, *a, **kw: None): + with patch("ceph_secrets.module.BACKENDS", {"mon": broken_cls}): + with patch.object(MgrModule, "get_module_option", return_value="mon"): + with pytest.raises(RuntimeError, match="Failed to initialize secrets backend"): + Module.__init__(object.__new__(Module), mgr) + + +# --------------------------------------------------------------------------- +# Module RPC surface +# --------------------------------------------------------------------------- + +class TestModuleRPC: + @pytest.fixture + def mod(self, mgr): + return _make_module(mgr) + + def test_secret_get_epoch_zero(self, mod): + assert mod.secret_get_epoch("cephadm") == 0 + + def test_secret_get_epoch_after_set(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert mod.secret_get_epoch("ns") == 1 + + def test_secret_set_and_get(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + result = mod.secret_get("ns", SecretScope.GLOBAL, "", "pw", reveal=True) + assert result is not None + assert result["data"] == "s3cr3t" + + def test_secret_get_without_reveal_hides_data(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + result = mod.secret_get("ns", SecretScope.GLOBAL, "", "pw", reveal=False) + assert result is not None + assert "data" not in result + + def test_secret_get_missing_returns_none(self, mod): + assert mod.secret_get("ns", SecretScope.GLOBAL, "", "ghost") is None + + def test_secret_get_version_existing(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert mod.secret_get_version("ns", SecretScope.GLOBAL, "", "k") == 1 + + def test_secret_get_version_missing_returns_none(self, mod): + assert mod.secret_get_version("ns", SecretScope.GLOBAL, "", "ghost") is None + + def test_secret_get_value_existing(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + assert mod.secret_get_value("ns", SecretScope.GLOBAL, "", "pw") == "s3cr3t" + + def test_secret_get_value_missing_returns_none(self, mod): + assert mod.secret_get_value("ns", SecretScope.GLOBAL, "", "ghost") is None + + def test_secret_get_value_opaque_string(self, mod): + # Any string is valid — including one that looks like JSON + mod.secret_set("ns", SecretScope.GLOBAL, "", "creds", '{"u": "a", "p": "b"}') + assert mod.secret_get_value("ns", SecretScope.GLOBAL, "", "creds") == '{"u": "a", "p": "b"}' + + def test_secret_get_value_corrupt_raises_data_error(self, mgr, mod): + mgr.set_store("secret_store/v1/ns/global/bad", "{not json") + with pytest.raises(CephSecretDataError): + mod.secret_get_value("ns", SecretScope.GLOBAL, "", "bad") + + def test_secret_get_versions_batch(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "a", "data-a") + mod.secret_set("ns", SecretScope.GLOBAL, "", "b", "data-b") + result = mod.secret_get_versions([ + "secret:/ns/global/a", + "secret:/ns/global/b", + "secret:/ns/global/ghost", + ]) + assert result["secret:/ns/global/a"] == 1 + assert result["secret:/ns/global/b"] == 1 + assert result["secret:/ns/global/ghost"] is None + + def test_secret_get_versions_skips_invalid_uris(self, mod): + result = mod.secret_get_versions(["not-a-uri", "secret:/ns/badscope/key"]) + assert len(result) == 0 + + def test_secret_get_versions_empty_list(self, mod): + assert mod.secret_get_versions([]) == {} + + def test_secret_get_versions_corrupt_raises_data_error(self, mgr, mod): + """Corruption must not be silently swallowed as a missing/invalid URI.""" + mgr.set_store("secret_store/v1/ns/global/bad", "{not json") + with pytest.raises(CephSecretDataError): + mod.secret_get_versions(["secret:/ns/global/bad"]) + + def test_secret_set_returns_metadata(self, mod): + result = mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert "metadata" in result + assert result["metadata"]["version"] == 1 + + def test_secret_set_empty_data_raises(self, mod): + with pytest.raises(CephSecretException, match="must not be empty"): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "") + + def test_secret_rm_existing(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert mod.secret_rm("ns", SecretScope.GLOBAL, "", "k") is True + + def test_secret_rm_nonexistent(self, mod): + assert mod.secret_rm("ns", SecretScope.GLOBAL, "", "ghost") is False + + def test_secret_ls_empty(self, mod): + assert mod.secret_ls(namespace="ns") == {} + + def test_secret_ls_returns_records(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "a", "data-a") + mod.secret_set("ns", SecretScope.GLOBAL, "", "b", "data-b") + out = mod.secret_ls(namespace="ns") + assert "ns/global/a" in out + assert "ns/global/b" in out + + def test_secret_ls_scope_filter(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "g", "data-g") + mod.secret_set("ns", SecretScope.SERVICE, "prom", "auth", "data-auth") + out = mod.secret_ls(namespace="ns", scope="service") + assert all("service" in k for k in out.keys()) + + def test_secret_ls_with_target(self, mod): + mod.secret_set("ns", SecretScope.SERVICE, "prom", "auth", "data-auth") + out = mod.secret_ls(namespace="ns", scope="service", target="prom") + assert "ns/service/prom/auth" in out + + def test_secret_ls_show_internals(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + out = mod.secret_ls(namespace="ns", show_internals=True) + rec = out["ns/global/k"] + assert "policy" in rec + + def test_secret_ls_key_format_global(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "k", "data-x") + out = mod.secret_ls(namespace="ns") + assert "ns/global/k" in out + for k in out: + assert "//" not in k + + def test_scan_refs(self, mod): + result = mod.scan_refs({"key": "secret:/ns/global/pw"}, namespace="ns") + assert "secret:/ns/global/pw" in result + + def test_scan_unresolved_refs_all_missing(self, mod): + result = mod.scan_unresolved_refs("secret:/ns/global/ghost", namespace="ns") + assert "secret:/ns/global/ghost" in result + + def test_scan_unresolved_refs_exists(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "pw", "x") + result = mod.scan_unresolved_refs("secret:/ns/global/pw", namespace="ns") + assert result == [] + + def test_resolve_object(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + result = mod.resolve_object("secret:/ns/global/pw") + assert result == "s3cr3t" + + +# --------------------------------------------------------------------------- +# Module internal ref-based methods +# --------------------------------------------------------------------------- + +class TestModuleInternals: + @pytest.fixture + def mod(self, mgr): + return _make_module(mgr) + + def _ref(self, ns="ns", scope=SecretScope.GLOBAL, target="", name="key"): + return SecretRef(ns, scope, target, name) + + def test_secret_get_existing(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "key", "data-x") + result = mod._secret_get(self._ref()) + assert result is not None + + def test_secret_get_not_found(self, mod): + assert mod._secret_get(self._ref(name="ghost")) is None + + def test_secret_get_corrupt_raises_data_error(self, mgr, mod): + mgr.set_store("secret_store/v1/ns/global/bad", "{not json") + with pytest.raises(CephSecretDataError): + mod._secret_get(self._ref(name="bad")) + + def test_secret_get_version_existing(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "key", "data-x") + assert mod._secret_get_version(self._ref()) == 1 + + def test_secret_get_version_not_found(self, mod): + assert mod._secret_get_version(self._ref(name="ghost")) is None + + def test_secret_get_version_corrupt_raises_data_error(self, mgr, mod): + mgr.set_store("secret_store/v1/ns/global/bad", "{not json") + with pytest.raises(CephSecretDataError): + mod._secret_get_version(self._ref(name="bad")) + + def test_secret_set_returns_metadata_without_data(self, mod): + result = mod._secret_set(self._ref(), "data-x") + assert "metadata" in result + assert "data" not in result + + def test_secret_rm_existing(self, mod): + mod._secret_set(self._ref(), "data-x") + assert mod._secret_rm(self._ref()) is True + + def test_secret_rm_nonexistent(self, mod): + assert mod._secret_rm(self._ref(name="ghost")) is False + + +# --------------------------------------------------------------------------- +# CLI handler logic (path-based dispatch) +# --------------------------------------------------------------------------- + +class TestModuleCLIHandlers: + @pytest.fixture + def mod(self, mgr): + return _make_module(mgr) + + def _ok(self, result: Any) -> Any: + """Unwrap a Responder tuple (retcode, body, status) → parsed dict. + Falls through unchanged when Responder is a no-op stub.""" + if isinstance(result, tuple): + retcode, body, _ = result + assert retcode == 0, f"unexpected error retcode {retcode}: {body}" + return json.loads(body) + return result + + def _assert_error(self, result: Any, match: str = "") -> None: + """Assert a CLI call failed — either via a non-zero tuple or ErrorResponse. + Never accepts a raw CephSecretException (would mean _handle_secret_errors broke).""" + from object_format import ErrorResponse + if isinstance(result, tuple): + retcode, body, status = result + assert retcode != 0, "expected non-zero retcode" + if match: + assert match in (body + status).lower(), \ + f"expected {match!r} in response, got body={body!r} status={status!r}" + elif isinstance(result, ErrorResponse): + if match: + assert match in str(result).lower() + else: + raise AssertionError( + f"expected error tuple or ErrorResponse, got {type(result).__name__}: {result!r}" + ) + + def test_cli_get_by_path_found(self, mod): + mod.secret_set("ns", SecretScope.GLOBAL, "", "key", "data-x") + result = self._ok(mod._cli_secret_get_by_path(path="ns/global/key")) + assert "metadata" in result + + def test_cli_get_by_path_not_found(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_get_by_path(path="ns/global/ghost") + self._assert_error(result, match="not found") + except ErrorResponse as e: + assert "not found" in str(e).lower() + + def test_cli_get_by_path_bad_path(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_get_by_path(path="ns/badscope/key") + self._assert_error(result) + except ErrorResponse: + pass + + def test_cli_get_corrupt_raises_data_error(self, mgr, mod): + from object_format import ErrorResponse + mgr.set_store("secret_store/v1/ns/global/bad", "{not json") + # CephSecretDataError is a CephSecretException subclass so _handle_secret_errors + # wraps it into ErrorResponse; Responder formats it as a non-zero tuple. + try: + result = mod._cli_secret_get_by_path(path="ns/global/bad") + self._assert_error(result) + except ErrorResponse: + pass + + def test_cli_set_by_path(self, mod): + result = self._ok(mod._cli_secret_set_by_path( + path="ns/global/key", + inbuf="s3cr3t" + )) + assert result["metadata"]["version"] == 1 + + def test_cli_set_by_path_no_inbuf(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_set_by_path(path="ns/global/key", inbuf=None) + self._assert_error(result, match="-i") + except ErrorResponse as e: + assert "-i" in str(e) + + def test_cli_set_by_path_empty_data(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_set_by_path(path="ns/global/key", inbuf="") + self._assert_error(result, match="must not be empty") + except ErrorResponse as e: + assert "must not be empty" in str(e).lower() + + def test_cli_rm_by_path_existing(self, mod): + mod._cli_secret_set_by_path(path="ns/global/key", inbuf='{"v": "x"}') + result = self._ok(mod._cli_secret_rm_by_path(path="ns/global/key")) + assert result["status"] == "removed" + + def test_cli_rm_by_path_not_found(self, mod): + result = self._ok(mod._cli_secret_rm_by_path(path="ns/global/ghost")) + assert result["status"] == "not_found" + + def test_cli_rm_by_path_bad_path(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_rm_by_path(path="ns/badscope/key") + self._assert_error(result) + except ErrorResponse: + pass + + def test_cli_ls(self, mod): + mod._cli_secret_set_by_path(path="ns/global/a", inbuf='{"v": "1"}') + mod._cli_secret_set_by_path(path="ns/global/b", inbuf='{"v": "2"}') + result = self._ok(mod._cli_secret_ls(namespace="ns")) + assert "ns/global/a" in result + assert "ns/global/b" in result + + def test_cli_get_with_reveal(self, mod): + mod._cli_secret_set_by_path(path="ns/global/pw", inbuf="s3cr3t") + result = self._ok(mod._cli_secret_get_by_path(path="ns/global/pw", reveal=True)) + assert result["data"] == "s3cr3t" + + def test_cli_get_without_reveal_hides_data(self, mod): + mod._cli_secret_set_by_path(path="ns/global/pw", inbuf="s3cr3t") + result = self._ok(mod._cli_secret_get_by_path(path="ns/global/pw", reveal=False)) + assert "data" not in result + + def _get_value(self, result: Any) -> str: + """Unwrap a get-value tuple (retcode, body, status) → raw string.""" + if isinstance(result, tuple): + retcode, body, _ = result + assert retcode == 0, f"unexpected error retcode {retcode}: {body}" + return body + return result + + def test_cli_get_value_returns_raw_string(self, mod): + mod._cli_secret_set_by_path(path="ns/global/pw", inbuf="s3cr3t") + result = self._get_value(mod._cli_secret_get_value_by_path(path="ns/global/pw")) + assert result == "s3cr3t" + + def test_cli_get_value_not_found_raises(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_get_value_by_path(path="ns/global/ghost") + self._assert_error(result) + except ErrorResponse: + pass + + def test_cli_get_value_bad_path_raises(self, mod): + from object_format import ErrorResponse + try: + result = mod._cli_secret_get_value_by_path(path="ns/badscope/key") + self._assert_error(result) + except ErrorResponse: + pass + + def test_cli_get_value_opaque_json_string(self, mod): + payload = '{"u": "a", "p": "b"}' + mod._cli_secret_set_by_path(path="ns/global/creds", inbuf=payload) + result = self._get_value(mod._cli_secret_get_value_by_path(path="ns/global/creds")) + assert result == payload diff --git a/src/pybind/mgr/ceph_secrets/tests/test_secret_backend.py b/src/pybind/mgr/ceph_secrets/tests/test_secret_backend.py new file mode 100644 index 00000000000..1b98dd67355 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/test_secret_backend.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""Tests for the SecretStorageBackend ABC and contract.""" +from __future__ import annotations + +import pytest +from abc import ABC + +from ceph_secrets.secret_backend import SecretStorageBackend +from ceph_secrets_types import SecretScope + + +class TestSecretStorageBackend: + def test_is_abstract(self): + assert issubclass(SecretStorageBackend, ABC) + + def test_cannot_instantiate_directly(self): + with pytest.raises(TypeError): + SecretStorageBackend() # type: ignore[abstract] + + def test_concrete_subclass_instantiates(self, store): + """SecretStoreMon (the concrete impl) must satisfy the ABC.""" + assert isinstance(store, SecretStorageBackend) + + def test_abstract_methods_defined(self): + abstract = SecretStorageBackend.__abstractmethods__ + assert 'get' in abstract + assert 'set' in abstract + assert 'rm' in abstract + assert 'ls' in abstract + assert 'get_epoch' in abstract + assert 'bump_epoch' in abstract + + def test_partial_implementation_still_abstract(self): + """Subclass missing any abstract method stays abstract.""" + class PartialImpl(SecretStorageBackend): + def get(self, ns, scope, target, name): + pass + + def set(self, ns, scope, target, name, data, user_made=True, editable=True): + pass + + def rm(self, ns, scope, target, name): + pass + + def ls(self, namespace=None, scope=None, target=None): + pass + + def get_epoch(self, namespace): + pass + # missing bump_epoch + + with pytest.raises(TypeError): + PartialImpl() # type: ignore[abstract] + + def test_full_implementation_is_instantiable(self): + + class FullImpl(SecretStorageBackend): + + def get(self, ns, scope, target, name): + return None + + def set(self, ns, scope, target, name, data, user_made=True, editable=True): + pass + + def rm(self, ns, scope, target, name): + return False + + def ls(self, namespace=None, scope=None, target=None): + return [] + + def get_epoch(self, namespace): + return 0 + + def bump_epoch(self, namespace): + return 1 + + impl = FullImpl() + assert impl.get("ns", SecretScope.GLOBAL, "", "k") is None + assert impl.get_epoch("ns") == 0 + assert impl.bump_epoch("ns") == 1 diff --git a/src/pybind/mgr/ceph_secrets/tests/test_secret_mgr.py b/src/pybind/mgr/ceph_secrets/tests/test_secret_mgr.py new file mode 100644 index 00000000000..a4be58a4432 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/test_secret_mgr.py @@ -0,0 +1,339 @@ +# -*- coding: utf-8 -*- +"""Tests for ceph_secrets.secret_mgr (SecretMgr).""" +from __future__ import annotations + +import pytest + +from ceph_secrets_types import ( + SecretScope, SecretRef, + CephSecretException, CephSecretNotFoundError, + BadSecretURI, +) + + +# ============================================================ +# make_ref +# ============================================================ + +class TestMakeRef: + def test_basic(self, secret_mgr): + ref = secret_mgr.make_ref("ns", SecretScope.GLOBAL, "", "key") + assert isinstance(ref, SecretRef) + assert ref.namespace == "ns" + + def test_scope_as_string(self, secret_mgr): + ref = secret_mgr.make_ref("ns", "host", "node1", "ssh_key") + assert ref.scope == SecretScope.HOST + + def test_bad_scope_raises(self, secret_mgr): + with pytest.raises(CephSecretException): + secret_mgr.make_ref("ns", "badscope", "", "key") + + def test_bad_namespace_raises(self, secret_mgr): + with pytest.raises(CephSecretException): + secret_mgr.make_ref("bad ns!", SecretScope.GLOBAL, "", "key") + + +# ============================================================ +# get / get_value +# ============================================================ + +class TestGet: + def test_get_existing(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + ref = SecretRef("ns", SecretScope.GLOBAL, "", "pw") + rec = secret_mgr.get(ref) + assert rec.data == "s3cr3t" + + def test_get_missing_raises(self, secret_mgr): + ref = SecretRef("ns", SecretScope.GLOBAL, "", "ghost") + with pytest.raises(CephSecretNotFoundError): + secret_mgr.get(ref) + + def test_get_value(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + ref = SecretRef("ns", SecretScope.GLOBAL, "", "pw") + assert secret_mgr.get_value(ref) == "s3cr3t" + + def test_get_value_opaque_string(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "creds", '{"u": "admin", "p": "pw"}') + ref = SecretRef("ns", SecretScope.GLOBAL, "", "creds") + val = secret_mgr.get_value(ref) + assert isinstance(val, str) + assert val == '{"u": "admin", "p": "pw"}' + + def test_get_value_missing_raises(self, secret_mgr): + ref = SecretRef("ns", SecretScope.GLOBAL, "", "ghost") + with pytest.raises(CephSecretNotFoundError): + secret_mgr.get_value(ref) + + +# ============================================================ +# set +# ============================================================ + +class TestSet: + def test_set_global(self, secret_mgr): + rec = secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + assert rec.metadata.version == 1 + assert rec.data == "data-v1" + + def test_set_service(self, secret_mgr): + rec = secret_mgr.set("ns", SecretScope.SERVICE, "prom", "auth", "user-a") + assert rec.target == "prom" + + def test_set_custom(self, secret_mgr): + rec = secret_mgr.set("ns", SecretScope.CUSTOM, "", "a/b/c", "tok") + assert rec.name == "a/b/c" + + def test_set_non_str_raises(self, secret_mgr): + with pytest.raises(CephSecretException, match="string"): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", {"not": "a-string"}) # type: ignore[arg-type] + + def test_set_empty_string_raises(self, secret_mgr): + with pytest.raises(CephSecretException, match="must not be empty"): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", "") + + def test_set_preserves_whitespace_and_newlines(self, secret_mgr): + payload = " secret-value\n" + rec = secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", payload) + assert rec.data == payload + assert secret_mgr.get_value(SecretRef("ns", SecretScope.GLOBAL, "", "k")) == payload + + def test_set_increments_version(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + rec = secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", "data-v2") + assert rec.metadata.version == 2 + + def test_set_scope_string(self, secret_mgr): + rec = secret_mgr.set("ns", "global", "", "k", "data-x") + assert rec.scope == SecretScope.GLOBAL + + +# ============================================================ +# rm +# ============================================================ + +class TestRm: + def test_rm_existing(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert secret_mgr.rm("ns", SecretScope.GLOBAL, "", "k") is True + + def test_rm_nonexistent(self, secret_mgr): + assert secret_mgr.rm("ns", SecretScope.GLOBAL, "", "ghost") is False + + +# ============================================================ +# ls +# ============================================================ + +class TestLs: + def test_ls_empty(self, secret_mgr): + assert secret_mgr.ls(namespace="ns") == [] + + def test_ls_returns_records(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "a", "data-a") + secret_mgr.set("ns", SecretScope.GLOBAL, "", "b", "data-b") + recs = secret_mgr.ls(namespace="ns") + assert len(recs) == 2 + + def test_ls_scope_filter(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "g", "data-g") + secret_mgr.set("ns", SecretScope.SERVICE, "prom", "auth", "data-auth") + recs = secret_mgr.ls(namespace="ns", scope="service") + assert len(recs) == 1 + assert recs[0].scope == SecretScope.SERVICE + + +# ============================================================ +# scan_refs / scan_unresolved_refs +# ============================================================ + +class TestScanRefs: + def test_scan_simple_string(self, secret_mgr): + obj = "secret:/ns/global/pw" + refs = secret_mgr.scan_refs(obj, namespace="ns") + uris = {r.to_uri() for r in refs} + assert "secret:/ns/global/pw" in uris + + def test_scan_in_dict(self, secret_mgr): + obj = {"key": "secret:/ns/global/pw"} + refs = secret_mgr.scan_refs(obj, namespace="ns") + assert len(refs) == 1 + + def test_scan_in_list(self, secret_mgr): + obj = ["secret:/ns/global/pw", "secret:/ns/host/node1/ssh"] + refs = secret_mgr.scan_refs(obj, namespace="ns") + assert len(refs) == 2 + + def test_scan_nested(self, secret_mgr): + obj = {"a": {"b": "secret:/ns/global/pw"}} + refs = secret_mgr.scan_refs(obj, namespace="ns") + assert len(refs) == 1 + + def test_scan_no_refs(self, secret_mgr): + obj = {"plain": "value"} + assert secret_mgr.scan_refs(obj, namespace="ns") == set() + + def test_scan_bad_uri_yields_bad_secret_uri(self, secret_mgr): + obj = "secret:/ns/badscope/key" + refs = secret_mgr.scan_refs(obj, namespace="ns") + bad = [r for r in refs if isinstance(r, BadSecretURI)] + assert len(bad) == 1 + + def test_scan_unresolved_all_exist(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "x") + obj = "secret:/ns/global/pw" + unresolved = secret_mgr.scan_unresolved_refs(obj, namespace="ns") + assert len(unresolved) == 0 + + def test_scan_unresolved_missing(self, secret_mgr): + obj = "secret:/ns/global/ghost" + unresolved = secret_mgr.scan_unresolved_refs(obj, namespace="ns") + assert len(unresolved) == 1 + + def test_scan_unresolved_bad_uri_is_unresolved(self, secret_mgr): + obj = "secret:/ns/badscope/key" + unresolved = secret_mgr.scan_unresolved_refs(obj, namespace="ns") + assert len(unresolved) == 1 + + def test_scan_unresolved_embedded_uri_is_unresolved(self, secret_mgr): + # Even though the referenced secret exists, an embedded (non-whole-value) + # URI is never resolvable, so it must be reported as unresolved so that + # pre-deploy validation catches it. + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "x") + obj = {"auth": "Bearer secret:/ns/global/pw"} + unresolved = secret_mgr.scan_unresolved_refs(obj, namespace="ns") + assert len(unresolved) == 1 + + +# ============================================================ +# resolve_object +# ============================================================ + +class TestResolveObject: + def test_resolve_secret(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + result = secret_mgr.resolve_object("secret:/ns/global/pw") + assert result == "s3cr3t" + + def test_resolve_opaque_json_string(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "creds", '{"u": "a", "p": "b"}') + result = secret_mgr.resolve_object("secret:/ns/global/creds") + assert isinstance(result, str) + assert result == '{"u": "a", "p": "b"}' + + def test_resolve_in_dict(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + result = secret_mgr.resolve_object({"password": "secret:/ns/global/pw"}) + assert result["password"] == "s3cr3t" + + def test_resolve_in_list(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "a", "x") + result = secret_mgr.resolve_object(["secret:/ns/global/a", "plain"]) + assert result[0] == "x" + assert result[1] == "plain" + + def test_resolve_in_tuple(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "t", "y") + result = secret_mgr.resolve_object(("secret:/ns/global/t",)) + assert result == ("y",) + + def test_resolve_non_secret_string_unchanged(self, secret_mgr): + result = secret_mgr.resolve_object("just a normal string") + assert result == "just a normal string" + + def test_resolve_non_string_unchanged(self, secret_mgr): + assert secret_mgr.resolve_object(42) == 42 + assert secret_mgr.resolve_object(None) is None + + def test_resolve_missing_secret_raises(self, secret_mgr): + with pytest.raises(CephSecretException): + secret_mgr.resolve_object("secret:/ns/global/ghost") + + def test_resolve_invalid_uri_raises(self, secret_mgr): + with pytest.raises(CephSecretException): + secret_mgr.resolve_object("secret:/ns/badscope/key") + + def test_resolve_edge_whitespace_tolerated(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + assert secret_mgr.resolve_object(" secret:/ns/global/pw ") == "s3cr3t" + + def test_resolve_newline_whitespace_tolerated(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + assert secret_mgr.resolve_object("\nsecret:/ns/global/pw\n") == "s3cr3t" + + def test_resolve_ref_with_suffix_raises(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + with pytest.raises(CephSecretException): + secret_mgr.resolve_object("secret:/ns/global/pw suffix") + + def test_resolve_embedded_uri_raises(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + with pytest.raises(CephSecretException, match="embedded"): + secret_mgr.resolve_object("Bearer secret:/ns/global/pw") + + def test_resolve_embedded_uri_in_dict_raises(self, secret_mgr): + secret_mgr.set("ns", SecretScope.GLOBAL, "", "pw", "s3cr3t") + with pytest.raises(CephSecretException, match="embedded"): + secret_mgr.resolve_object({"auth": "Bearer secret:/ns/global/pw"}) + + def test_resolve_plain_string_whitespace_preserved(self, secret_mgr): + # non-secret strings pass through byte-for-byte, including whitespace + assert secret_mgr.resolve_object(" plain value ") == " plain value " + + +# ============================================================ +# scan_refs — edge cases +# ============================================================ + +class TestScanRefsEdgeCases: + def test_embedded_refs_rejected_as_bad(self, secret_mgr): + # A string that embeds URIs inside other text is NOT a whole-value + # reference; it is surfaced as a single BadSecretURI, not parsed into + # multiple SecretRefs. + obj = "use secret:/ns/global/a and secret:/ns/global/b" + refs = secret_mgr.scan_refs(obj, namespace="ns") + assert len(refs) == 1 + bad = [r for r in refs if isinstance(r, BadSecretURI)] + assert len(bad) == 1 + assert bad[0].raw == obj + + def test_duplicate_refs_deduped(self, secret_mgr): + obj = ["secret:/ns/global/pw", "secret:/ns/global/pw"] + refs = secret_mgr.scan_refs(obj, namespace="ns") + uris = [r.to_uri() for r in refs] + assert uris.count("secret:/ns/global/pw") == 1 + + def test_ref_followed_by_punctuation(self, secret_mgr): + # "secret:/ns/global/pw," is a whole-value string that starts with the + # prefix but is not a valid URI (trailing comma in name) → BadSecretURI. + obj = "secret:/ns/global/pw," + refs = secret_mgr.scan_refs(obj, namespace="ns") + bad = [r for r in refs if isinstance(r, BadSecretURI)] + assert len(bad) == 1 + + def test_whole_value_ref_with_edge_whitespace(self, secret_mgr): + # Surrounding whitespace around an otherwise-clean URI is tolerated. + obj = " secret:/ns/global/pw " + refs = secret_mgr.scan_refs(obj, namespace="ns") + uris = {r.to_uri() for r in refs if isinstance(r, SecretRef)} + assert "secret:/ns/global/pw" in uris + + def test_embedded_ref_in_dict_value_is_bad(self, secret_mgr): + obj = {"auth": "Bearer secret:/ns/global/pw"} + refs = secret_mgr.scan_refs(obj, namespace="ns") + bad = [r for r in refs if isinstance(r, BadSecretURI)] + assert len(bad) == 1 + + def test_ref_inside_tuple(self, secret_mgr): + obj = ("secret:/ns/global/pw",) + refs = secret_mgr.scan_refs(obj, namespace="ns") + assert len(refs) == 1 + + def test_cross_namespace_ref_is_scanned(self, secret_mgr): + """scan_refs finds refs regardless of namespace — namespace arg does not filter.""" + obj = "secret:/other/global/pw" + refs = secret_mgr.scan_refs(obj, namespace="ns") + uris = {r.to_uri() for r in refs} + assert "secret:/other/global/pw" in uris diff --git a/src/pybind/mgr/ceph_secrets/tests/test_secret_store.py b/src/pybind/mgr/ceph_secrets/tests/test_secret_store.py new file mode 100644 index 00000000000..5473944d695 --- /dev/null +++ b/src/pybind/mgr/ceph_secrets/tests/test_secret_store.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +"""Tests for ceph_secrets.secret_store (SecretStoreMon + data model).""" +from __future__ import annotations + +import pytest + +# conftest injects stubs; just import production code after. +from ceph_secrets.secret_store import ( + SecretRecord, + SecretMetadata, + SecretPolicy, + SECRET_STORE_PREFIX, + SECRET_STORE_FORMAT_VERSION, + _checked_ref, + _checked_namespace, + _epoch_key, + _now_iso, + _expect_bool, + _expect_str, + _expect_positive_int, + _expect_object, + _reject_unknown_keys, + _require_keys, +) +from ceph_secrets_types import ( + SecretScope, SecretRef, + CephSecretException, CephSecretDataError, +) + + +# ============================================================ +# Helpers / primitive validators +# ============================================================ + +class TestPrimitiveHelpers: + def test_now_iso_format(self): + ts = _now_iso() + import re + assert re.match(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$', ts) + + def test_expect_bool_ok(self): + assert _expect_bool("f", True) is True + assert _expect_bool("f", False) is False + + def test_expect_bool_bad(self): + with pytest.raises(CephSecretDataError, match="must be a boolean"): + _expect_bool("f", 1) + + def test_expect_str_ok(self): + assert _expect_str("f", "hello") == "hello" + + def test_expect_str_bad(self): + with pytest.raises(CephSecretDataError, match="must be a string"): + _expect_str("f", 42) + + def test_expect_positive_int_ok(self): + assert _expect_positive_int("f", 1) == 1 + assert _expect_positive_int("f", 99) == 99 + + def test_expect_positive_int_zero(self): + with pytest.raises(CephSecretDataError): + _expect_positive_int("f", 0) + + def test_expect_positive_int_bool_rejected(self): + with pytest.raises(CephSecretDataError): + _expect_positive_int("f", True) + + def test_expect_object_ok(self): + d = {"a": 1} + assert _expect_object("o", d) is d + + def test_expect_object_bad(self): + with pytest.raises(CephSecretDataError, match="must be a JSON object"): + _expect_object("o", [1, 2]) + + def test_reject_unknown_keys(self): + with pytest.raises(CephSecretDataError, match="unknown field"): + _reject_unknown_keys("L", {"a": 1, "z": 2}, {"a"}) + + def test_require_keys_missing(self): + with pytest.raises(CephSecretDataError, match="missing required"): + _require_keys("L", {"a": 1}, {"a", "b"}) + + def test_checked_namespace_valid(self): + assert _checked_namespace("cephadm") == "cephadm" + + def test_checked_namespace_bad(self): + with pytest.raises(CephSecretDataError): + _checked_namespace("bad ns!") + + def test_epoch_key_format(self): + assert _epoch_key("cephadm") == "secret_store/meta/cephadm/_epoch" + + def test_checked_ref_valid(self): + ref = _checked_ref("ns", SecretScope.GLOBAL, "", "key") + assert isinstance(ref, SecretRef) + + def test_checked_ref_bad_scope(self): + with pytest.raises(CephSecretDataError): + _checked_ref("ns", "bad", "", "key") + + +# ============================================================ +# SecretMetadata +# ============================================================ + +class TestSecretMetadata: + def test_defaults(self): + m = SecretMetadata(version=1, created="2024-01-01T00:00:00Z", updated="2024-01-01T00:00:00Z") + assert m.version == 1 + + def test_to_json(self): + m = SecretMetadata(version=2, created="2024-01-01T00:00:00Z", updated="2024-01-02T00:00:00Z") + d = m.to_json() + assert d["version"] == 2 + assert "created" in d + assert "updated" in d + + def test_from_json_roundtrip(self): + m = SecretMetadata(version=3, created="2024-01-01T00:00:00Z", updated="2024-01-02T00:00:00Z") + m2 = SecretMetadata.from_json(m.to_json()) + assert m2.version == 3 + + def test_from_json_missing_field(self): + with pytest.raises(CephSecretDataError, match="missing required"): + SecretMetadata.from_json({"version": 1, "created": "x"}) + + def test_from_json_unknown_field(self): + with pytest.raises(CephSecretDataError, match="unknown"): + SecretMetadata.from_json({"version": 1, "created": "x", "updated": "y", "extra": 1}) + + def test_version_zero_raises(self): + with pytest.raises(CephSecretDataError): + SecretMetadata(version=0, created="x", updated="y") + + def test_non_dict_raises(self): + with pytest.raises(CephSecretDataError): + SecretMetadata.from_json("not a dict") + + +# ============================================================ +# SecretPolicy +# ============================================================ + +class TestSecretPolicy: + def test_defaults(self): + p = SecretPolicy() + assert p.user_made is True + assert p.editable is True + + def test_to_json(self): + p = SecretPolicy(user_made=False, editable=True) + d = p.to_json() + assert d["user_made"] is False + + def test_from_json_roundtrip(self): + p = SecretPolicy(user_made=True, editable=False) + p2 = SecretPolicy.from_json(p.to_json()) + assert p2.editable is False + + def test_from_json_missing(self): + with pytest.raises(CephSecretDataError): + SecretPolicy.from_json({"user_made": True}) + + def test_from_json_bad_type(self): + with pytest.raises(CephSecretDataError, match="must be a boolean"): + SecretPolicy.from_json({"user_made": 1, "editable": True}) + + def test_non_bool_raises(self): + with pytest.raises(CephSecretDataError): + SecretPolicy(user_made="yes", editable=True) # type: ignore[arg-type] + + +# ============================================================ +# SecretRecord +# ============================================================ + +class TestSecretRecord: + def _ref(self, scope=SecretScope.GLOBAL, target="", name="key"): + return SecretRef("ns", scope, target, name) + + def _meta(self): + return SecretMetadata(version=1, created="2024-01-01T00:00:00Z", updated="2024-01-01T00:00:00Z") + + def test_basic_construction(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="s3cr3t") + assert rec.namespace == "ns" + assert rec.scope == SecretScope.GLOBAL + assert rec.name == "key" + + def test_to_public_json_no_data(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="s3cr3t") + out = rec.to_public_json(include_data=False) + assert "data" not in out + assert "metadata" in out + + def test_to_public_json_with_data(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="s3cr3t") + out = rec.to_public_json(include_data=True) + assert out["data"] == "s3cr3t" + + def test_to_public_json_with_ref(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="x") + out = rec.to_public_json(include_ref=True) + assert out["ref"]["namespace"] == "ns" + assert out["ref"]["scope"] == "global" + + def test_to_public_json_with_policy(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="x") + out = rec.to_public_json(include_policy=True) + assert "policy" in out + + def test_to_store_json(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="x") + stored = rec.to_store_json() + assert stored["format_version"] == SECRET_STORE_FORMAT_VERSION + assert "data" in stored + assert "policy" in stored + + def test_from_store_json_roundtrip(self): + ref = self._ref() + rec = SecretRecord(ref=ref, metadata=self._meta(), data="pw") + rec2 = SecretRecord.from_store_json(ref, rec.to_store_json()) + assert rec2.data == "pw" + assert rec2.metadata.version == 1 + + def test_from_store_json_wrong_version(self): + ref = self._ref() + payload = { + "format_version": 99, + "metadata": {"version": 1, "created": "x", "updated": "y"}, + "policy": {"user_made": True, "editable": True}, + "data": "x", + } + with pytest.raises(CephSecretDataError, match="unsupported"): + SecretRecord.from_store_json(ref, payload) + + def test_from_store_json_missing_key(self): + ref = self._ref() + with pytest.raises(CephSecretDataError, match="missing required"): + SecretRecord.from_store_json(ref, {"format_version": 1, "metadata": {}}) + + def test_bad_ref_type_raises(self): + with pytest.raises(CephSecretDataError, match="must be a SecretRef"): + SecretRecord(ref="not-a-ref", metadata=self._meta(), data="x") # type: ignore[arg-type] + + def test_bad_metadata_type_raises(self): + with pytest.raises(CephSecretDataError): + SecretRecord(ref=self._ref(), metadata={"version": 1}, data="x") # type: ignore[arg-type] + + def test_bad_data_type_raises(self): + with pytest.raises(CephSecretDataError): + SecretRecord(ref=self._ref(), metadata=self._meta(), data={"not": "a-string"}) # type: ignore[arg-type] + + def test_empty_data_raises(self): + with pytest.raises(CephSecretDataError, match="must not be empty"): + SecretRecord(ref=self._ref(), metadata=self._meta(), data="") + + def test_data_in_store_json(self): + rec = SecretRecord(ref=self._ref(), metadata=self._meta(), data="myvalue") + stored = rec.to_store_json() + assert stored["data"] == "myvalue" + + def test_ident(self): + ref = self._ref() + rec = SecretRecord(ref=ref, metadata=self._meta(), data="x") + assert rec.ident() == ("ns", "global", "", "key") + + +# ============================================================ +# SecretStoreMon – epoch +# ============================================================ + +class TestSecretStoreMon_Epoch: + def test_initial_epoch_is_zero(self, store): + assert store.get_epoch("cephadm") == 0 + + def test_bump_epoch(self, store): + assert store.bump_epoch("cephadm") == 1 + assert store.bump_epoch("cephadm") == 2 + + def test_namespace_isolation(self, store): + store.bump_epoch("cephadm") + store.bump_epoch("cephadm") + assert store.get_epoch("rook") == 0 + + def test_epoch_key_stored_correctly(self, mgr, store): + store.bump_epoch("myns") + assert mgr.get_store("secret_store/meta/myns/_epoch") == "1" + + def test_corrupted_epoch_returns_zero(self, mgr, store): + mgr.set_store("secret_store/meta/ns/_epoch", "notanumber") + assert store.get_epoch("ns") == 0 + + +# ============================================================ +# SecretStoreMon – CRUD +# ============================================================ + +class TestSecretStoreMon_Crud: + def test_set_and_get_global(self, store): + store.set("ns", SecretScope.GLOBAL, "", "pw", "secret") + rec = store.get("ns", SecretScope.GLOBAL, "", "pw") + assert rec is not None + assert rec.data == "secret" + + def test_get_missing_returns_none(self, store): + assert store.get("ns", SecretScope.GLOBAL, "", "noexist") is None + + def test_set_increments_version(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v2") + rec = store.get("ns", SecretScope.GLOBAL, "", "k") + assert rec.metadata.version == 2 + + def test_set_preserves_created_timestamp(self, store): + import time + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + rec1 = store.get("ns", SecretScope.GLOBAL, "", "k") + time.sleep(1) + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v2") + rec2 = store.get("ns", SecretScope.GLOBAL, "", "k") + assert rec1.metadata.created == rec2.metadata.created + assert rec2.metadata.updated != rec2.metadata.created + + def test_set_service_scope(self, store): + store.set("ns", SecretScope.SERVICE, "prometheus", "auth", "admin") + rec = store.get("ns", SecretScope.SERVICE, "prometheus", "auth") + assert rec.data == "admin" + + def test_set_host_scope(self, store): + store.set("ns", SecretScope.HOST, "node1", "ssh", "abc") + rec = store.get("ns", SecretScope.HOST, "node1", "ssh") + assert rec.data == "abc" + + def test_set_custom_scope(self, store): + store.set("ns", SecretScope.CUSTOM, "", "a/b/c", "tok") + rec = store.get("ns", SecretScope.CUSTOM, "", "a/b/c") + assert rec.data == "tok" + + def test_set_bumps_epoch(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + assert store.get_epoch("ns") == 1 + + def test_set_non_str_data_raises(self, store): + with pytest.raises(CephSecretException): + store.set("ns", SecretScope.GLOBAL, "", "k", {"not": "a-string"}) # type: ignore[arg-type] + + def test_set_empty_data_raises(self, store): + with pytest.raises(CephSecretException, match="must not be empty"): + store.set("ns", SecretScope.GLOBAL, "", "k", "") + + def test_set_non_editable_raises(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1", editable=False) + with pytest.raises(CephSecretException, match="not editable"): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v2") + + def test_rm_existing(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-x") + assert store.rm("ns", SecretScope.GLOBAL, "", "k") is True + assert store.get("ns", SecretScope.GLOBAL, "", "k") is None + + def test_rm_nonexistent(self, store): + assert store.rm("ns", SecretScope.GLOBAL, "", "ghost") is False + + def test_rm_bumps_epoch(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-x") + epoch_after_set = store.get_epoch("ns") + store.rm("ns", SecretScope.GLOBAL, "", "k") + assert store.get_epoch("ns") == epoch_after_set + 1 + + def test_rm_nonexistent_no_epoch_bump(self, store): + assert store.get_epoch("ns") == 0 + store.rm("ns", SecretScope.GLOBAL, "", "ghost") + assert store.get_epoch("ns") == 0 + + def test_get_corrupted_json_raises(self, mgr, store): + mgr.set_store( + f"{SECRET_STORE_PREFIX}ns/global/badkey", "NOT JSON" + ) + with pytest.raises(CephSecretDataError): + store.get("ns", SecretScope.GLOBAL, "", "badkey") + + def test_set_with_bad_scope_string(self, store): + with pytest.raises(CephSecretDataError): + store.set("ns", "badscope", "", "k", "data-v1") + + +# ============================================================ +# SecretStoreMon – ls +# ============================================================ + +class TestSecretStoreMon_Ls: + def test_ls_empty(self, store): + assert store.ls(namespace="ns") == [] + + def test_ls_returns_all(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k1", "data-1") + store.set("ns", SecretScope.GLOBAL, "", "k2", "data-2") + recs = store.ls(namespace="ns") + assert len(recs) == 2 + + def test_ls_filter_scope(self, store): + store.set("ns", SecretScope.GLOBAL, "", "g", "data-g") + store.set("ns", SecretScope.SERVICE, "prom", "auth", "data-auth") + recs = store.ls(namespace="ns", scope=SecretScope.GLOBAL) + assert len(recs) == 1 + assert recs[0].scope == SecretScope.GLOBAL + + def test_ls_filter_target(self, store): + store.set("ns", SecretScope.SERVICE, "prom", "a1", "data-a1") + store.set("ns", SecretScope.SERVICE, "grafana", "a2", "data-a2") + recs = store.ls(namespace="ns", scope=SecretScope.SERVICE, target="prom") + assert len(recs) == 1 + assert recs[0].target == "prom" + + def test_ls_namespace_isolation(self, store): + store.set("ns1", SecretScope.GLOBAL, "", "k", "data-ns1") + store.set("ns2", SecretScope.GLOBAL, "", "k", "data-ns2") + recs = store.ls(namespace="ns1") + assert len(recs) == 1 + + def test_ls_custom_scope(self, store): + store.set("ns", SecretScope.CUSTOM, "", "a/b/c", "data-abc") + store.set("ns", SecretScope.CUSTOM, "", "x/y", "data-xy") + recs = store.ls(namespace="ns", scope=SecretScope.CUSTOM) + assert len(recs) == 2 + + def test_ls_sorted(self, store): + store.set("ns", SecretScope.GLOBAL, "", "zz", "data-zz") + store.set("ns", SecretScope.GLOBAL, "", "aa", "data-aa") + recs = store.ls(namespace="ns") + names = [r.name for r in recs] + assert names == sorted(names) + + def test_ls_invalid_namespace(self, store): + with pytest.raises(CephSecretDataError): + store.ls(namespace="bad ns!") + + def test_ls_scope_as_string(self, store): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1") + recs = store.ls(namespace="ns", scope="global") + assert len(recs) == 1 + + def test_ls_corrupted_json_raises(self, mgr, store): + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/global/bad", "NOT-JSON") + with pytest.raises(CephSecretDataError): + store.ls(namespace="ns") + + def test_ls_no_filter(self, store): + store.set("ns1", SecretScope.GLOBAL, "", "a", "data-a") + store.set("ns2", SecretScope.GLOBAL, "", "b", "data-b") + recs = store.ls() + assert len(recs) == 2 + + def test_ls_kv_key_structure_validation(self, mgr, store): + """A key with empty segments inside the prefix should raise CephSecretDataError.""" + mgr.set_store(f"{SECRET_STORE_PREFIX}ns//global/k", "{}") + with pytest.raises(CephSecretDataError, match="empty path component"): + store.ls(namespace="ns") + + +# ============================================================ +# TestSecretStoreMon_Ls — malformed persisted-record cases +# ============================================================ + +class TestSecretStoreMon_Ls_Malformed: + def test_ls_too_few_segments(self, mgr, store): + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/global", "{}") + with pytest.raises(CephSecretDataError, match="unexpected key structure"): + store.ls(namespace="ns") + + def test_ls_invalid_scope_in_key(self, mgr, store): + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/badscope/key", "{}") + with pytest.raises(CephSecretDataError, match="invalid scope"): + store.ls(namespace="ns") + + def test_ls_global_with_extra_segment(self, mgr, store): + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/global/target/name", "{}") + with pytest.raises(CephSecretDataError, match="unexpected global key structure"): + store.ls(namespace="ns") + + def test_ls_service_with_missing_segment(self, mgr, store): + # service needs 4 parts (ns/service/target/name); 3 is too few + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/service/onlytarget", "{}") + with pytest.raises(CephSecretDataError, match="unexpected targeted-scope key structure"): + store.ls(namespace="ns") + + def test_ls_payload_not_object(self, mgr, store): + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/global/k", '["not", "object"]') + with pytest.raises(CephSecretDataError, match="not a JSON object"): + store.ls(namespace="ns") + + def test_ls_payload_valid_json_missing_fields(self, mgr, store): + # Valid JSON object but missing required secret record fields + mgr.set_store(f"{SECRET_STORE_PREFIX}ns/global/k", '{"unexpected": 1}') + with pytest.raises(CephSecretDataError): + store.ls(namespace="ns") + + +# ============================================================ +# TestSecretStoreMon_Crud — editable=False allows rm by design +# ============================================================ + +class TestSecretStoreMon_EditableRm: + def test_rm_non_editable_secret_is_allowed(self, store): + """rm ignores editable — deletion is always permitted by design.""" + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1", editable=False) + result = store.rm("ns", SecretScope.GLOBAL, "", "k") + assert result is True + + def test_set_non_editable_blocks_update(self, store): + """Confirmed: set refuses to update a non-editable secret.""" + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v1", editable=False) + with pytest.raises(CephSecretException): + store.set("ns", SecretScope.GLOBAL, "", "k", "data-v2") diff --git a/src/pybind/mgr/ceph_secrets_client.py b/src/pybind/mgr/ceph_secrets_client.py index 2386de71043..3240641dbd8 100644 --- a/src/pybind/mgr/ceph_secrets_client.py +++ b/src/pybind/mgr/ceph_secrets_client.py @@ -249,8 +249,9 @@ class CephSecretsClient: rather than automatically by a Ceph component. Defaults to True; set to False for programmatically generated secrets. - editable: Whether the secret may be updated or removed by - automated tooling. Defaults to True. + editable: Whether the secret may be updated by automated + tooling. Deletion is always permitted regardless of + this flag. Defaults to True. Returns: A dict containing the written record's ``metadata`` object. The diff --git a/src/pybind/mgr/ceph_secrets_types.py b/src/pybind/mgr/ceph_secrets_types.py index ae2ccd58672..88cdce45422 100644 --- a/src/pybind/mgr/ceph_secrets_types.py +++ b/src/pybind/mgr/ceph_secrets_types.py @@ -196,6 +196,11 @@ def parse_secret_uri(uri: str) -> SecretRef: if not isinstance(uri, str): raise CephSecretException('secret uri must be a string') + if uri != uri.strip(): + raise CephSecretException( + f'Invalid secret uri {uri!r}: leading/trailing whitespace is not allowed' + ) + parsed = urlparse(uri) if parsed.scheme != SECRET_SCHEME: raise CephSecretException(f'Not a secret uri: {uri!r}') @@ -289,6 +294,11 @@ def parse_secret_path(path: str) -> SecretRef: if not isinstance(path, str): raise CephSecretException('secret path must be a string') + if path != path.strip(): + raise CephSecretException( + f'Invalid secret path {path!r}: leading/trailing whitespace is not allowed' + ) + p = path.strip() if not p: raise CephSecretException('Invalid secret path: empty') diff --git a/src/pybind/mgr/tests/test_ceph_secrets_client.py b/src/pybind/mgr/tests/test_ceph_secrets_client.py new file mode 100644 index 00000000000..ab52077cd27 --- /dev/null +++ b/src/pybind/mgr/tests/test_ceph_secrets_client.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for ceph_secrets_client.py. +Placed at the same level as the module under test (src/pybind/mgr/). +""" +from __future__ import annotations + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import pytest +from typing import Any +from unittest.mock import MagicMock, call + +from ceph_secrets_client import CephSecretsClient, MgrRemote +from ceph_secrets_types import SecretScope + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_mgr(return_value: Any = None, raise_exc: Exception = None) -> MagicMock: + """Return a mock that satisfies the MgrRemote Protocol.""" + mgr = MagicMock() + if raise_exc is not None: + mgr.remote.side_effect = raise_exc + else: + mgr.remote.return_value = return_value + return mgr + + +def _client(return_value: Any = None, raise_exc: Exception = None) -> CephSecretsClient: + return CephSecretsClient(_make_mgr(return_value, raise_exc)) + + +# ============================================================ +# CephSecretsClient construction +# ============================================================ + +class TestConstruction: + def test_default_module_name(self): + mgr = MagicMock() + client = CephSecretsClient(mgr) + assert client.module == "ceph_secrets" + + def test_custom_module_name(self): + mgr = MagicMock() + client = CephSecretsClient(mgr, module="my_secrets") + assert client.module == "my_secrets" + + def test_stores_mgr(self): + mgr = MagicMock() + client = CephSecretsClient(mgr) + assert client.mgr is mgr + + +# ============================================================ +# _remote error handling +# ============================================================ + +class TestRemoteErrorHandling: + def test_passes_through_return_value(self): + client = _client(return_value=42) + assert client._remote("some_method", foo="bar") == 42 + + def test_wraps_exception_as_runtime_error(self): + client = _client(raise_exc=RuntimeError("module down")) + with pytest.raises(RuntimeError, match="Cannot call secrets mgr-module"): + client._remote("any_method") + + def test_exception_includes_module_name(self): + mgr = _make_mgr(raise_exc=Exception("boom")) + client = CephSecretsClient(mgr, module="my_secrets") + with pytest.raises(RuntimeError, match="my_secrets"): + client._remote("any_method") + + def test_exception_is_enabled_hint(self): + client = _client(raise_exc=Exception("unavailable")) + with pytest.raises(RuntimeError, match="enabled"): + client._remote("any_method") + + +# ============================================================ +# secret_get_epoch +# ============================================================ + +class TestSecretGetEpoch: + def test_calls_correct_method(self): + mgr = _make_mgr(return_value=5) + client = CephSecretsClient(mgr) + result = client.secret_get_epoch("cephadm") + mgr.remote.assert_called_once_with("ceph_secrets", "secret_get_epoch", namespace="cephadm") + assert result == 5 + + def test_returns_zero_epoch(self): + assert _client(return_value=0).secret_get_epoch("ns") == 0 + + +# ============================================================ +# secret_get +# ============================================================ + +class TestSecretGet: + def test_calls_remote_correctly(self): + mgr = _make_mgr(return_value={"metadata": {"version": 1}}) + client = CephSecretsClient(mgr) + result = client.secret_get("ns", SecretScope.GLOBAL, "", "pw") + mgr.remote.assert_called_once_with( + "ceph_secrets", "secret_get", + namespace="ns", scope=SecretScope.GLOBAL, + target="", name="pw", reveal=False, + ) + assert result["metadata"]["version"] == 1 + + def test_reveal_kwarg_forwarded(self): + mgr = _make_mgr(return_value={"metadata": {}, "data": "s3cr3t"}) + client = CephSecretsClient(mgr) + client.secret_get("ns", SecretScope.GLOBAL, "", "pw", reveal=True) + _, kwargs = mgr.remote.call_args + assert kwargs["reveal"] is True + + def test_returns_none_when_not_found(self): + assert _client(return_value=None).secret_get("ns", "global", "", "ghost") is None + + def test_scope_as_string(self): + mgr = _make_mgr(return_value=None) + client = CephSecretsClient(mgr) + client.secret_get("ns", "host", "node1", "ssh") + _, kwargs = mgr.remote.call_args + assert kwargs["scope"] == "host" + + +# ============================================================ +# secret_get_value +# ============================================================ + +class TestSecretGetValue: + def test_calls_remote(self): + mgr = _make_mgr(return_value="s3cr3t") + client = CephSecretsClient(mgr) + result = client.secret_get_value("ns", SecretScope.GLOBAL, "", "pw") + mgr.remote.assert_called_once_with( + "ceph_secrets", "secret_get_value", + namespace="ns", scope=SecretScope.GLOBAL, target="", name="pw", + ) + assert result == "s3cr3t" + + def test_returns_none_if_not_found(self): + assert _client(return_value=None).secret_get_value("ns", "global", "", "ghost") is None + + def test_returns_opaque_string(self): + payload = '{"u": "a", "p": "b"}' + result = _client(return_value=payload).secret_get_value("ns", "global", "", "creds") + assert result == payload + + def test_module_unreachable_raises(self): + with pytest.raises(RuntimeError, match="Cannot call"): + _client(raise_exc=Exception("down")).secret_get_value("ns", "global", "", "pw") + + +# secret_get_version +# ============================================================ + +class TestSecretGetVersion: + def test_calls_remote(self): + mgr = _make_mgr(return_value=3) + client = CephSecretsClient(mgr) + result = client.secret_get_version("ns", SecretScope.GLOBAL, "", "k") + mgr.remote.assert_called_once_with( + "ceph_secrets", "secret_get_version", + namespace="ns", scope=SecretScope.GLOBAL, target="", name="k", + ) + assert result == 3 + + def test_returns_none_if_not_found(self): + assert _client(return_value=None).secret_get_version("ns", "global", "", "ghost") is None + + +# ============================================================ +# secret_get_versions +# ============================================================ + +class TestSecretGetVersions: + def test_calls_remote_with_list(self): + uris = ["secret:/ns/global/a", "secret:/ns/global/b"] + expected = {"secret:/ns/global/a": 1, "secret:/ns/global/b": None} + mgr = _make_mgr(return_value=expected) + client = CephSecretsClient(mgr) + result = client.secret_get_versions(uris) + mgr.remote.assert_called_once_with("ceph_secrets", "secret_get_versions", uris=uris) + assert result == expected + + def test_returns_empty_dict_for_empty_list(self): + result = _client(return_value={}).secret_get_versions([]) + assert result == {} + + +# ============================================================ +# secret_set +# ============================================================ + +class TestSecretSet: + def test_calls_remote_with_defaults(self): + expected = {"metadata": {"version": 1}} + mgr = _make_mgr(return_value=expected) + client = CephSecretsClient(mgr) + result = client.secret_set("ns", SecretScope.GLOBAL, "", "pw", "x") + mgr.remote.assert_called_once_with( + "ceph_secrets", "secret_set", + namespace="ns", scope=SecretScope.GLOBAL, + target="", name="pw", data="x", + user_made=True, editable=True, + ) + assert result == expected + + def test_user_made_editable_overrides(self): + mgr = _make_mgr(return_value={}) + client = CephSecretsClient(mgr) + client.secret_set("ns", "global", "", "k", "x", user_made=False, editable=False) + _, kwargs = mgr.remote.call_args + assert kwargs["user_made"] is False + assert kwargs["editable"] is False + + def test_raises_on_module_error(self): + with pytest.raises(RuntimeError): + _client(raise_exc=Exception("down")).secret_set("ns", "global", "", "k", "x") + + +# ============================================================ +# secret_rm +# ============================================================ + +class TestSecretRm: + def test_calls_remote(self): + mgr = _make_mgr(return_value=True) + client = CephSecretsClient(mgr) + result = client.secret_rm("ns", SecretScope.GLOBAL, "", "k") + mgr.remote.assert_called_once_with( + "ceph_secrets", "secret_rm", + namespace="ns", scope=SecretScope.GLOBAL, target="", name="k", + ) + assert result is True + + def test_returns_false_when_not_found(self): + assert _client(return_value=False).secret_rm("ns", "global", "", "ghost") is False + + def test_truthy_value_coerced_to_bool(self): + # remote might return 1 instead of True + assert _client(return_value=1).secret_rm("ns", "global", "", "k") is True + + def test_falsy_value_coerced_to_bool(self): + assert _client(return_value=0).secret_rm("ns", "global", "", "k") is False + + +# ============================================================ +# scan_unresolved_refs +# ============================================================ + +class TestScanUnresolvedRefs: + def test_calls_remote(self): + expected = ["secret:/ns/global/missing"] + mgr = _make_mgr(return_value=expected) + client = CephSecretsClient(mgr) + result = client.scan_unresolved_refs({"k": "secret:/ns/global/missing"}, "ns") + mgr.remote.assert_called_once_with( + "ceph_secrets", "scan_unresolved_refs", + obj={"k": "secret:/ns/global/missing"}, namespace="ns", + ) + assert result == expected + + def test_returns_empty_when_all_resolved(self): + assert _client(return_value=[]).scan_unresolved_refs({}, "ns") == [] + + +# ============================================================ +# scan_refs +# ============================================================ + +class TestScanRefs: + def test_calls_remote(self): + expected = ["secret:/ns/global/pw"] + mgr = _make_mgr(return_value=expected) + client = CephSecretsClient(mgr) + result = client.scan_refs("secret:/ns/global/pw", "ns") + mgr.remote.assert_called_once_with( + "ceph_secrets", "scan_refs", + obj="secret:/ns/global/pw", namespace="ns", + ) + assert result == expected + + def test_returns_empty_for_no_refs(self): + assert _client(return_value=[]).scan_refs("no refs here", "ns") == [] + + +# ============================================================ +# resolve_object +# ============================================================ + +class TestResolveObject: + def test_calls_remote(self): + mgr = _make_mgr(return_value={"password": "s3cr3t"}) + client = CephSecretsClient(mgr) + result = client.resolve_object({"password": "secret:/ns/global/pw"}) + mgr.remote.assert_called_once_with( + "ceph_secrets", "resolve_object", + obj={"password": "secret:/ns/global/pw"}, + ) + assert result["password"] == "s3cr3t" + + def test_raises_on_unresolvable(self): + with pytest.raises(RuntimeError): + _client(raise_exc=Exception("missing")).resolve_object("secret:/ns/global/ghost") + + def test_returns_plain_value_unchanged(self): + result = _client(return_value="plain").resolve_object("plain") + assert result == "plain" diff --git a/src/pybind/mgr/tests/test_ceph_secrets_types.py b/src/pybind/mgr/tests/test_ceph_secrets_types.py new file mode 100644 index 00000000000..062af9b6fd9 --- /dev/null +++ b/src/pybind/mgr/tests/test_ceph_secrets_types.py @@ -0,0 +1,487 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for ceph_secrets_types.py. +Placed at the same level as the module under test (src/pybind/mgr/). +""" +from __future__ import annotations + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import pytest + +from ceph_secrets_types import ( + CephSecretException, + CephSecretDataError, + CephSecretNotFoundError, + SecretScope, + SecretRef, + BadSecretURI, + parse_secret_uri, + parse_secret_path, + validate_secret_namespace, + SECRET_SCHEME, + _validate_segment, + _validate_custom_path, + _quote_segment, + _quote_custom_path, +) + + +# ============================================================ +# Module constants +# ============================================================ + +class TestConstants: + def test_secret_scheme(self): + assert SECRET_SCHEME == "secret" + + +# ============================================================ +# Exception hierarchy +# ============================================================ + +class TestExceptions: + def test_base_is_exception(self): + assert issubclass(CephSecretException, Exception) + + def test_data_error_inherits_base(self): + assert issubclass(CephSecretDataError, CephSecretException) + + def test_not_found_inherits_base(self): + assert issubclass(CephSecretNotFoundError, CephSecretException) + + def test_can_raise_and_catch_base(self): + with pytest.raises(CephSecretException): + raise CephSecretException("test") + + def test_can_raise_and_catch_data_error_as_base(self): + with pytest.raises(CephSecretException): + raise CephSecretDataError("data") + + def test_can_raise_and_catch_not_found_as_base(self): + with pytest.raises(CephSecretException): + raise CephSecretNotFoundError("gone") + + +# ============================================================ +# _validate_segment +# ============================================================ + +class TestValidateSegment: + @pytest.mark.parametrize("v", [ + "simple", "with-dashes", "under_score", "dot.dot", "a1b2", + "UPPER", "Mixed123", + ]) + def test_valid(self, v): + _validate_segment("f", v) + + @pytest.mark.parametrize("bad", [ + "", " ", "has space", "a/b", "a:b", "star*", + ]) + def test_invalid(self, bad): + with pytest.raises(ValueError): + _validate_segment("f", bad) + + def test_ends_with_dot_invalid(self): + with pytest.raises(ValueError, match="must not end"): + _validate_segment("f", "end.") + + def test_non_string_raises(self): + with pytest.raises(ValueError, match="must be a string"): + _validate_segment("f", None) + + +# ============================================================ +# _validate_custom_path +# ============================================================ + +class TestValidateCustomPath: + @pytest.mark.parametrize("p", [ + "single", "a/b", "a/b/c", "deep/path/here", + ]) + def test_valid(self, p): + _validate_custom_path(p) + + def test_empty_string(self): + with pytest.raises(ValueError, match="must not be empty"): + _validate_custom_path("") + + def test_empty_segment_in_middle(self): + with pytest.raises(ValueError, match="empty segments"): + _validate_custom_path("a//b") + + def test_trailing_slash(self): + with pytest.raises(ValueError, match="empty segments"): + _validate_custom_path("a/b/") + + def test_leading_slash_empty_first_segment(self): + with pytest.raises(ValueError, match="empty segments"): + _validate_custom_path("/a/b") + + def test_segment_with_bad_chars(self): + with pytest.raises(ValueError): + _validate_custom_path("a/b c/d") + + +# ============================================================ +# _quote helpers +# ============================================================ + +class TestQuoteHelpers: + def test_quote_segment_no_slash(self): + # clean identifiers are unchanged + assert _quote_segment("cephadm") == "cephadm" + + def test_quote_custom_path_preserves_slash(self): + assert _quote_custom_path("a/b/c") == "a/b/c" + + +# ============================================================ +# validate_secret_namespace +# ============================================================ + +class TestValidateSecretNamespace: + def test_valid_namespace(self): + validate_secret_namespace("cephadm") + + def test_empty_namespace(self): + with pytest.raises(ValueError): + validate_secret_namespace("") + + def test_namespace_with_space(self): + with pytest.raises(ValueError): + validate_secret_namespace("bad name") + + +# ============================================================ +# SecretScope +# ============================================================ + +class TestSecretScope: + def test_all_values(self): + assert SecretScope.GLOBAL.value == "global" + assert SecretScope.SERVICE.value == "service" + assert SecretScope.HOST.value == "host" + assert SecretScope.CUSTOM.value == "custom" + + def test_from_str_valid(self): + for v in ("global", "service", "host", "custom"): + scope = SecretScope.from_str(v) + assert scope.value == v + + def test_from_str_invalid(self): + with pytest.raises(CephSecretException, match="Invalid secret scope"): + SecretScope.from_str("vault") + + def test_is_str_subclass(self): + assert isinstance(SecretScope.GLOBAL, str) + + # validate_fields – global + def test_global_valid(self): + SecretScope.GLOBAL.validate_fields("", "mykey") + + def test_global_nonempty_target_fails(self): + with pytest.raises(ValueError, match="target must be empty"): + SecretScope.GLOBAL.validate_fields("target", "key") + + def test_global_empty_name_fails(self): + with pytest.raises(ValueError): + SecretScope.GLOBAL.validate_fields("", "") + + # validate_fields – service / host + def test_service_valid(self): + SecretScope.SERVICE.validate_fields("prometheus", "auth") + + def test_service_empty_target_fails(self): + with pytest.raises(ValueError): + SecretScope.SERVICE.validate_fields("", "auth") + + def test_host_valid(self): + SecretScope.HOST.validate_fields("node1", "ssh_key") + + def test_host_empty_name_fails(self): + with pytest.raises(ValueError): + SecretScope.HOST.validate_fields("node1", "") + + # validate_fields – custom + def test_custom_valid_flat(self): + SecretScope.CUSTOM.validate_fields("", "some-key") + + def test_custom_valid_nested(self): + SecretScope.CUSTOM.validate_fields("", "a/b/c") + + def test_custom_nonempty_target_fails(self): + with pytest.raises(ValueError, match="target must be empty"): + SecretScope.CUSTOM.validate_fields("badtarget", "key") + + def test_custom_empty_name_fails(self): + with pytest.raises(ValueError): + SecretScope.CUSTOM.validate_fields("", "") + + +# ============================================================ +# SecretRef +# ============================================================ + +class TestSecretRef: + def test_global_ref_construction(self): + ref = SecretRef("ns", SecretScope.GLOBAL, "", "pw") + assert ref.namespace == "ns" + assert ref.scope == SecretScope.GLOBAL + assert ref.target == "" + assert ref.name == "pw" + + def test_service_ref(self): + ref = SecretRef("ns", SecretScope.SERVICE, "prom", "auth") + assert ref.target == "prom" + + def test_host_ref(self): + ref = SecretRef("ns", SecretScope.HOST, "n1", "k") + assert ref.name == "k" + + def test_custom_ref(self): + ref = SecretRef("ns", SecretScope.CUSTOM, "", "a/b/c") + assert ref.name == "a/b/c" + + def test_is_frozen(self): + ref = SecretRef("ns", SecretScope.GLOBAL, "", "k") + with pytest.raises(Exception): + ref.name = "other" # type: ignore[misc] + + def test_scope_coerced_from_string(self): + ref = SecretRef("ns", "host", "node1", "k") + assert ref.scope == SecretScope.HOST + + def test_invalid_scope_raises(self): + with pytest.raises(ValueError): + SecretRef("ns", "badscope", "", "k") + + def test_invalid_namespace_raises(self): + with pytest.raises(ValueError): + SecretRef("bad ns", SecretScope.GLOBAL, "", "k") + + def test_ident_global(self): + ref = SecretRef("ns", SecretScope.GLOBAL, "", "k") + assert ref.ident() == ("ns", "global", "", "k") + + def test_ident_service(self): + ref = SecretRef("ns", SecretScope.SERVICE, "t", "n") + assert ref.ident() == ("ns", "service", "t", "n") + + def test_to_uri_global(self): + ref = SecretRef("cephadm", SecretScope.GLOBAL, "", "pw") + assert ref.to_uri() == "secret:/cephadm/global/pw" + + def test_to_uri_service(self): + ref = SecretRef("ns", SecretScope.SERVICE, "prom", "auth") + assert ref.to_uri() == "secret:/ns/service/prom/auth" + + def test_to_uri_host(self): + ref = SecretRef("ns", SecretScope.HOST, "node1", "ssh") + assert ref.to_uri() == "secret:/ns/host/node1/ssh" + + def test_to_uri_custom(self): + ref = SecretRef("ns", SecretScope.CUSTOM, "", "a/b/c") + assert ref.to_uri() == "secret:/ns/custom/a/b/c" + + def test_equality(self): + r1 = SecretRef("ns", SecretScope.GLOBAL, "", "k") + r2 = SecretRef("ns", SecretScope.GLOBAL, "", "k") + assert r1 == r2 + + def test_inequality_different_name(self): + r1 = SecretRef("ns", SecretScope.GLOBAL, "", "k1") + r2 = SecretRef("ns", SecretScope.GLOBAL, "", "k2") + assert r1 != r2 + + +# ============================================================ +# BadSecretURI +# ============================================================ + +class TestBadSecretURI: + def test_construction(self): + b = BadSecretURI(raw="secret:/bad/scope/key", error="bad scope", namespace="ns") + assert b.raw == "secret:/bad/scope/key" + assert b.error == "bad scope" + + def test_to_uri_returns_raw(self): + b = BadSecretURI(raw="secret:/bad", error="err", namespace="ns") + assert b.to_uri() == "secret:/bad" + + def test_is_frozen(self): + b = BadSecretURI(raw="x", error="e", namespace="n") + with pytest.raises(Exception): + b.raw = "y" # type: ignore[misc] + + +# ============================================================ +# parse_secret_uri +# ============================================================ + +class TestParseSecretUri: + # --- valid inputs --- + def test_global(self): + ref = parse_secret_uri("secret:/ns/global/pw") + assert ref.scope == SecretScope.GLOBAL + assert ref.name == "pw" + + def test_service(self): + ref = parse_secret_uri("secret:/ns/service/prom/auth") + assert ref.scope == SecretScope.SERVICE + assert ref.target == "prom" + assert ref.name == "auth" + + def test_host(self): + ref = parse_secret_uri("secret:/ns/host/node1/ssh") + assert ref.target == "node1" + assert ref.name == "ssh" + + def test_custom_flat(self): + ref = parse_secret_uri("secret:/ns/custom/single") + assert ref.scope == SecretScope.CUSTOM + assert ref.name == "single" + + def test_custom_nested(self): + ref = parse_secret_uri("secret:/ns/custom/a/b/c") + assert ref.name == "a/b/c" + + # --- invalid inputs --- + def test_wrong_scheme(self): + with pytest.raises(CephSecretException, match="Not a secret uri"): + parse_secret_uri("http://example.com/foo") + + def test_authority_rejected(self): + with pytest.raises(CephSecretException, match="authority"): + parse_secret_uri("secret://ns/global/key") + + def test_query_string_rejected(self): + with pytest.raises(CephSecretException, match="query"): + parse_secret_uri("secret:/ns/global/key?x=y") + + def test_fragment_rejected(self): + with pytest.raises(CephSecretException, match="query"): + parse_secret_uri("secret:/ns/global/key#frag") + + def test_percent_encoding_rejected(self): + with pytest.raises(CephSecretException, match="percent-encoding"): + parse_secret_uri("secret:/ns/global/my%2Dkey") + + def test_too_short(self): + with pytest.raises(CephSecretException): + parse_secret_uri("secret:/ns/global") + + def test_bad_scope(self): + with pytest.raises(CephSecretException): + parse_secret_uri("secret:/ns/vault/key") + + def test_non_string_input(self): + with pytest.raises(CephSecretException, match="must be a string"): + parse_secret_uri(42) # type: ignore[arg-type] + + def test_global_with_extra_segment_rejected(self): + with pytest.raises(CephSecretException): + parse_secret_uri("secret:/ns/global/target/name") + + def test_service_missing_name(self): + with pytest.raises(CephSecretException): + parse_secret_uri("secret:/ns/service/prom") + + +# ============================================================ +# parse_secret_path +# ============================================================ + +class TestParseSecretPath: + # --- valid inputs --- + def test_global(self): + ref = parse_secret_path("cephadm/global/pw") + assert ref.namespace == "cephadm" + assert ref.scope == SecretScope.GLOBAL + assert ref.name == "pw" + + def test_service(self): + ref = parse_secret_path("ns/service/prom/auth") + assert ref.target == "prom" + assert ref.name == "auth" + + def test_host(self): + ref = parse_secret_path("ns/host/node1/ssh_key") + assert ref.name == "ssh_key" + + def test_custom_flat(self): + ref = parse_secret_path("ns/custom/flat") + assert ref.scope == SecretScope.CUSTOM + assert ref.name == "flat" + + def test_custom_nested(self): + ref = parse_secret_path("ns/custom/a/b/c") + assert ref.name == "a/b/c" + + def test_leading_slash_stripped(self): + ref = parse_secret_path("/ns/global/k") + assert ref.namespace == "ns" + + # --- invalid inputs --- + def test_empty_string(self): + with pytest.raises(CephSecretException, match="empty"): + parse_secret_path("") + + def test_non_string(self): + with pytest.raises(CephSecretException): + parse_secret_path(None) # type: ignore[arg-type] + + def test_too_few_segments(self): + with pytest.raises(CephSecretException, match="Use"): + parse_secret_path("ns/global") + + def test_double_slash(self): + with pytest.raises(CephSecretException): + parse_secret_path("ns//global/key") + + def test_trailing_slash(self): + with pytest.raises(CephSecretException, match="empty segment"): + parse_secret_path("ns/global/key/") + + def test_bad_scope(self): + with pytest.raises(CephSecretException): + parse_secret_path("ns/badscope/key") + + def test_global_extra_segment(self): + with pytest.raises(CephSecretException, match="global scope"): + parse_secret_path("ns/global/target/name") + + def test_service_missing_name(self): + with pytest.raises(CephSecretException, match="service"): + parse_secret_path("ns/service/target") + + def test_host_missing_name(self): + with pytest.raises(CephSecretException, match="host"): + parse_secret_path("ns/host/target") + + +# ============================================================ +# Whitespace / canonicality +# ============================================================ + +class TestParseSecretPathWhitespace: + @pytest.mark.parametrize("path", [ + " ns/global/key", + "ns/global/key ", + "\tns/global/key", + "ns/global/key\n", + ]) + def test_rejects_outer_whitespace(self, path): + with pytest.raises(CephSecretException): + parse_secret_path(path) + + +class TestParseSecretUriWhitespace: + def test_rejects_leading_whitespace(self): + with pytest.raises(CephSecretException): + parse_secret_uri(" secret:/ns/global/key") + + def test_rejects_trailing_whitespace(self): + with pytest.raises(CephSecretException): + parse_secret_uri("secret:/ns/global/key ") From ead4ef7f9af55088b27a43969bcc0e093ea85ed4 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 8 Jun 2026 11:54:17 +0200 Subject: [PATCH 186/596] doc/mgr/ceph_secrets: add documentation for the ceph_secrets module Document CLI commands (set/get/get-value/ls/rm), the Python API via CephSecretsClient, secret URI embedding and resolution, and epoch-based change detection. Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- doc/mgr/ceph_secrets.rst | 437 +++++++++++++++++++++++++++++++++++++++ doc/mgr/index.rst | 1 + 2 files changed, 438 insertions(+) create mode 100644 doc/mgr/ceph_secrets.rst diff --git a/doc/mgr/ceph_secrets.rst b/doc/mgr/ceph_secrets.rst new file mode 100644 index 00000000000..45675c002d2 --- /dev/null +++ b/doc/mgr/ceph_secrets.rst @@ -0,0 +1,437 @@ +.. _mgr-ceph-secrets: + +=================== +Ceph Secrets Module +=================== + +The ``ceph_secrets`` manager module provides centralised secret storage for +Ceph operators and Ceph manager modules. Instead of embedding plaintext +credentials in service specifications or configuration objects, secrets are +stored once and referenced by URI. Ceph manager modules such as cephadm +and rook can store secret payloads centrally, reference them by URI, and +resolve them to plaintext only when needed at deploy time. + +For example, a cephadm-managed service may need an API token. Instead of +embedding that token directly in the service specification, an operator can +store it once: + +.. prompt:: bash $ + + ceph secret set cephadm/service/my-service/api_token -i /tmp/api-token + +and then use the URI +``secret:/cephadm/service/my-service/api_token`` in the +service spec instead of the plaintext token. A cephadm integration can then +resolve the URI at deploy time and write the token only into the daemon files +that need it. + +Secrets are stored in the Mon KV store under the ``secret_store/v1/`` prefix +and are organised by namespace, scope, and name. Each secret is versioned and +carries ``created``/``updated`` timestamps. A per-namespace epoch counter +is incremented on every ``set`` and on any ``rm`` that actually removes a +secret, allowing consumers to detect changes without fetching the full secret +list. + +.. note:: + + The ``mon`` backend stores secrets in the Mon KV store, which is not an + external KMS or vault. Users and MGR modules with sufficient Ceph + permissions can still reveal or resolve stored secret values. Namespaces + provide logical and storage isolation, not an authorisation boundary. + +If the module is not already enabled, run:: + + ceph mgr module enable ceph_secrets + +.. contents:: + :local: + :depth: 2 + + +Concepts +======== + +Namespace +--------- + +A namespace is a logical storage boundary, typically matching the name of +the consuming MGR module (e.g. ``cephadm``, ``rook``). Secrets in different +namespaces are stored independently and have independent epoch counters. +Namespaces are not an authorisation boundary; any caller with access to the +``ceph_secrets`` module can read secrets from any namespace. + +Scope +----- + +Within a namespace, every secret has a scope that encodes what the secret +belongs to: + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Scope + - Meaning + - CLI path form + * - ``global`` + - Cluster-wide secret, not tied to any specific target + - ``/global/`` + * - ``service`` + - Secret for a specific named service + - ``/service//`` + * - ``host`` + - Secret for a specific host + - ``/host//`` + * - ``custom`` + - Arbitrary slash-delimited path under the namespace + - ``/custom/`` + +Path grammar +------------ + +All path segments (namespace, scope target, name, and each segment of a +custom path) must match ``[A-Za-z0-9._-]+`` and must not end with ``'.'``. +Empty segments, percent-encoding, and leading/trailing whitespace are +rejected. + +Custom scope paths may contain multiple slash-separated segments (e.g. +``cephadm/custom/app/db/password``). Two custom paths that share a common +prefix are independent secrets; ``cephadm/custom/a/b`` and +``cephadm/custom/a/b/c`` do not conflict. + +Secret URIs +----------- + +Internally, secrets are identified by URIs of the form:: + + secret://global/ + secret://service// + secret://host// + secret://custom/ + +These URIs appear in the output of :ref:`scan_refs ` +and may be embedded in configuration objects as opaque references to be +resolved at deploy time. + + +CLI Reference +============= + +All CLI commands use the path form described above. Responses are JSON by +default; pass ``--format yaml`` for YAML output. + +.. _mgr-ceph-secrets-set: + +secret set +---------- + +Create or update a secret. The input file content is stored as an opaque string:: + + ceph secret set -i + +Secret data must not be empty. Other contents, including leading or trailing +whitespace and final newlines, are preserved exactly. Callers that need to +store structured data should encode it themselves, for example as JSON, and +decode it after retrieval or resolution. + +If the secret already exists its data is replaced and its version +incremented, unless the existing secret policy marks it non-editable (set via +the Python API with ``editable=False``), in which case the update is rejected. +The ``created`` timestamp is set on the first write and is never changed +thereafter; ``updated`` is refreshed on every write. + +Example: + +.. prompt:: bash $ + + ceph secret set cephadm/service/my-service/api_token -i /tmp/api-token + {"metadata": {"version": 1, "created": "2025-06-01T12:00:00Z", "updated": "2025-06-01T12:00:00Z"}} + +.. _mgr-ceph-secrets-get: + +secret get +---------- + +Retrieve the metadata (and, optionally, the data) for a secret:: + + ceph secret get [--reveal] [--format {json|yaml}] + +Without ``--reveal``, the ``data`` field is omitted from the response to +avoid accidental exposure in terminal output or logs. With ``--reveal``, +``data`` contains the stored opaque string. + +Example: + +.. prompt:: bash $ + + ceph secret get cephadm/service/my-service/api_token + { + "metadata": { + "version": 1, + "created": "2025-06-01T12:00:00Z", + "updated": "2025-06-01T12:00:00Z" + } + } + + ceph secret get cephadm/service/my-service/api_token --reveal + { + "metadata": { + "version": 1, + "created": "2025-06-01T12:00:00Z", + "updated": "2025-06-01T12:00:00Z" + }, + "data": "api-token-value" + } + +.. _mgr-ceph-secrets-get-value: + +secret get-value +---------------- + +Return the raw secret data string directly, with no JSON envelope:: + + ceph secret get-value + +Unlike ``secret get --reveal``, the output is the stored string itself, +making it suitable for use in shell scripts and pipelines. Returns an +error if the secret does not exist. + +Example: + +.. prompt:: bash $ + + ceph secret get-value cephadm/service/my-service/api_token + api-token-value + +.. _mgr-ceph-secrets-ls: + +secret ls +--------- + +List secrets, optionally filtered by namespace, scope, and/or target:: + + ceph secret ls [--namespace ] [--scope ] + [--sec_target ] [--reveal] [--show_internals] + [--format {json|yaml}] + +Without filters, all secrets across all namespaces are listed. +``--reveal`` includes the stored opaque data string in each output record. +``--show_internals`` additionally includes the ``policy`` object (``user_made`` +and ``editable`` flags) in each record. + +Example: + +.. prompt:: bash $ + + ceph secret ls --namespace cephadm --scope host --sec_target node1 + { + "cephadm/host/node1/ssh_key": { + "metadata": { + "version": 3, + "created": "2025-05-10T08:00:00Z", + "updated": "2025-06-01T09:00:00Z" + }, + "ref": { + "namespace": "cephadm", + "scope": "host", + "target": "node1", + "name": "ssh_key" + } + } + } + +.. _mgr-ceph-secrets-rm: + +secret rm +--------- + +Remove a secret:: + + ceph secret rm + +The operation is idempotent: removing a secret that does not exist succeeds +and reports ``"status": "not_found"`` rather than an error. + +Example: + +.. prompt:: bash $ + + ceph secret rm cephadm/service/my-service/api_token + {"status": "removed"} + + ceph secret rm cephadm/service/my-service/api_token + {"status": "not_found"} + +.. _mgr-ceph-secrets-get-epoch: + +Epoch +----- + +The epoch for a namespace is accessible via the Python API only (see +:ref:`Epoch-based change detection `). There is +no CLI command for it. + + +Module API +========== + +Other MGR modules should consume ``ceph_secrets`` via ``CephSecretsClient`` +(``src/pybind/mgr/ceph_secrets_client.py``) rather than calling +``mgr.remote()`` directly. The client file, along with +``ceph_secrets_types.py``, lives at the top level of ``src/pybind/mgr/`` so +any MGR module can import it without depending on the ``ceph_secrets`` package +internals. + +.. code-block:: python + + from ceph_secrets_client import CephSecretsClient + from ceph_secrets_types import SecretScope + + client = CephSecretsClient(self) # self is a MgrModule instance + + # Store a secret as an opaque string + client.secret_set( + namespace="cephadm", + scope=SecretScope.HOST, + target="node1", + name="ssh_key", + data="AQB...==", + ) + + # Retrieve metadata (no data unless reveal=True) + rec = client.secret_get("cephadm", SecretScope.HOST, "node1", "ssh_key") + if rec: + version = rec["metadata"]["version"] + + # Remove + client.secret_rm("cephadm", SecretScope.HOST, "node1", "ssh_key") + +CephSecretsClient methods +-------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Method + - Description + * - ``secret_set(namespace, scope, target, name, data, ...)`` + - Create or update a secret. ``data`` must be a non-empty opaque + string. Increments the version and refreshes ``updated`` on each + call; ``created`` is set only on the first write. Optional + ``user_made`` and ``editable`` flags control the stored policy; + ``editable=False`` blocks future updates but does not prevent removal. + * - ``secret_get(namespace, scope, target, name, reveal=False)`` + - Return the secret's metadata dict, plus the opaque ``data`` string if + *reveal* is True. Returns ``None`` if the secret does not exist. + * - ``secret_get_value(namespace, scope, target, name)`` + - Return the stored opaque string directly, or ``None`` if the secret + does not exist. Use this when only the value is needed and the + metadata envelope is not required. + * - ``secret_get_version(namespace, scope, target, name)`` + - Return the current version integer, or ``None`` if not found. A + cheaper alternative to ``secret_get`` when only change-detection is needed. + * - ``secret_get_versions(uris)`` + - Batch-fetch version numbers for a list of canonical ``secret:/...`` + URIs. Returns a dict keyed by URI; missing secrets map to ``None``. + Malformed URIs are skipped entirely, so a missing key indicates + malformed input rather than an absent secret. + * - ``secret_rm(namespace, scope, target, name)`` + - Remove a secret. Idempotent: returns ``False`` if not found. + * - ``secret_get_epoch(namespace)`` + - Return the current epoch for the namespace. Use as a cheap + change-detector before a full refresh. + * - ``scan_refs(obj, namespace)`` + - Walk a JSON-like object and return secret-like reference strings found + within it. This includes valid whole-value secret URIs and malformed + or embedded secret-like references that should be reported to the + caller. Only canonical ``secret:/...`` URIs should be passed to + ``secret_get_versions``. + * - ``scan_unresolved_refs(obj, namespace)`` + - Like ``scan_refs`` but returns only references that are missing, + malformed, embedded inside a larger string, or cannot currently be + read successfully. Useful for pre-flight validation. + * - ``resolve_object(obj)`` + - Walk a JSON-like object and replace every whole-value ``secret:/...`` + URI string with the stored opaque string for the referenced secret. + +.. _mgr-ceph-secrets-scan: + +Secret URI embedding and resolution +----------------------------------- + +Configuration objects that need to reference secrets without embedding +plaintext can store ``secret:/...`` URIs as string values. The module +resolves them on demand: + +.. code-block:: python + + spec = { + "service_type": "my-service", + "spec": { + "api_token": " secret:/cephadm/service/my-service/api_token ", + }, + } + + # Check for missing, malformed, embedded, or unresolvable secret references + unresolved = client.scan_unresolved_refs(spec, namespace="cephadm") + if unresolved: + raise RuntimeError(f"Unresolved secret references: {unresolved}") + + # Replace URI references with plaintext values + resolved = client.resolve_object(spec) + # resolved["spec"]["api_token"] now holds the stored API token + +.. note:: + + Resolution replaces the entire string value of a field. Surrounding + whitespace around a URI reference is ignored, so values such as + ``" secret:/cephadm/global/foo "`` are accepted. Embedding a secret URI + inside a larger string (for example ``"Bearer secret:/..."``) is not + supported; after trimming surrounding whitespace, the field value must be + the URI. + + The resolved value is the stored opaque string. The module does not parse + stored data as JSON and does not support field-level URI selection. If a + caller stores structured data, it must encode and decode that structure + itself. + +.. _mgr-ceph-secrets-epoch: + +Epoch-based change detection +---------------------------- + +Consumers that maintain a local cache of secrets can use the epoch to avoid +unnecessary full refreshes: + +.. code-block:: python + + def maybe_refresh(client, namespace, last_epoch): + current_epoch = client.secret_get_epoch(namespace) + if current_epoch == last_epoch: + return last_epoch # nothing changed + # ... do a full refresh ... + return current_epoch + + last_epoch = 0 + last_epoch = maybe_refresh(client, "cephadm", last_epoch) + +The epoch is incremented by every successful ``set`` operation and by +``rm`` only when an existing secret is actually removed (an idempotent +``rm`` returning ``"not_found"`` does not bump the epoch). It starts at 0 +for a namespace that has never been written to, and mutations in one namespace +never affect another namespace's epoch. + + +Configuration +============= + +.. mgr_module:: ceph_secrets +.. confval:: secrets_backend + + :type: str + :default: ``mon`` + + The storage backend used for secrets. Currently only the Mon KV store + (``mon``) is supported. This option is reserved for future backends + (e.g. HashiCorp Vault). diff --git a/doc/mgr/index.rst b/doc/mgr/index.rst index e3c9f688204..25b629dbb80 100644 --- a/doc/mgr/index.rst +++ b/doc/mgr/index.rst @@ -47,5 +47,6 @@ sensible. MDS Autoscaler module NFS module SMB module + Secrets module Progress Module CLI API Commands module From 8a28a1578e75b22476f8c0c4e1ba0688073df94d Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Thu, 4 Jun 2026 12:45:58 +0200 Subject: [PATCH 187/596] mgr: adding ceph_secrets_xxx.py files to the build/packaging Fixes: https://tracker.ceph.com/issues/74562 Assisted-by: Claude Assisted-by: ChatGPT Signed-off-by: Redouane Kachach --- ceph.spec.in | 2 ++ src/pybind/mgr/CMakeLists.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ceph.spec.in b/ceph.spec.in index 780889d5e73..352a70d2c14 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -1925,6 +1925,8 @@ fi %{_datadir}/ceph/mgr/mgr_module.* %{_datadir}/ceph/mgr/mgr_util.* %{_datadir}/ceph/mgr/object_format.* +%{_datadir}/ceph/mgr/ceph_secrets_client.* +%{_datadir}/ceph/mgr/ceph_secrets_types.* %{_datadir}/ceph/mgr/cherrypy_mgr.* %{_unitdir}/ceph-mgr@.service %{_unitdir}/ceph-mgr.target diff --git a/src/pybind/mgr/CMakeLists.txt b/src/pybind/mgr/CMakeLists.txt index 36abd1bb53f..f416f16e922 100644 --- a/src/pybind/mgr/CMakeLists.txt +++ b/src/pybind/mgr/CMakeLists.txt @@ -59,5 +59,5 @@ set(mgr_modules install(DIRECTORY ${mgr_modules} DESTINATION ${CEPH_INSTALL_DATADIR}/mgr ${mgr_module_install_excludes}) -install(FILES mgr_module.py mgr_util.py object_format.py cherrypy_mgr.py +install(FILES mgr_module.py mgr_util.py object_format.py cherrypy_mgr.py ceph_secrets_client.py ceph_secrets_types.py DESTINATION ${CEPH_INSTALL_DATADIR}/mgr) From 95a8c70be86569ef19f3d5c1b11f7cebed682db5 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Thu, 11 Jun 2026 14:49:04 +0530 Subject: [PATCH 188/596] mgr/dashboard: fix zone creation in rgw service creation form The zone creation request from the rgw service creation form was missing the tier_type, sync_from and sync_from_all properties as a result the zone creation was failing. This PR tends to fix this issue. Fixes: https://tracker.ceph.com/issues/77263 Signed-off-by: Aashish Sharma --- .../create-rgw-service-entities.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component.ts index 03206a6ddc3..e849c1bdbe6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component.ts @@ -75,6 +75,9 @@ export class CreateRgwServiceEntitiesComponent extends CdForm implements OnInit this.zone.system_key = new SystemKey(); this.zone.system_key.access_key = ''; this.zone.system_key.secret_key = ''; + this.zone.tier_type = ''; + this.zone.sync_from = ''; + this.zone.sync_from_all = true; this.rgwRealmService .create(this.realm, true) .toPromise() From e98037a639cec14dd9d70b9938b961da0df18cd2 Mon Sep 17 00:00:00 2001 From: Prasanna Kumar Kalever Date: Thu, 9 Apr 2026 00:09:17 +0530 Subject: [PATCH 189/596] rbd-mirror: fix missing initialization of Peer UUID Initialize the uuid member in the Peer constructor and include it in stream output operator to ensure correct peer identification in logs. Signed-off-by: Prasanna Kumar Kalever --- src/tools/rbd_mirror/Types.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tools/rbd_mirror/Types.h b/src/tools/rbd_mirror/Types.h index c4357d9f2d8..f5e9c67fde8 100644 --- a/src/tools/rbd_mirror/Types.h +++ b/src/tools/rbd_mirror/Types.h @@ -103,7 +103,8 @@ struct Peer { librados::IoCtx& io_ctx, const RemotePoolMeta& remote_pool_meta, MirrorStatusUpdater* mirror_status_updater) - : io_ctx(io_ctx), + : uuid(uuid), + io_ctx(io_ctx), remote_pool_meta(remote_pool_meta), mirror_status_updater(mirror_status_updater) { } @@ -115,7 +116,8 @@ struct Peer { template std::ostream& operator<<(std::ostream& os, const Peer& peer) { - return os << peer.remote_pool_meta; + return os << "uuid=" << peer.uuid << ", " + << peer.remote_pool_meta; } struct PeerSpec { From cae12eccddf0499f9c4dffa75ee23fd4b088ffb3 Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Thu, 11 Jun 2026 11:33:42 +0000 Subject: [PATCH 190/596] crimson/osd: fix PGBackend::remove() to return ENOENT on no-op deletes PGBackend::remove() was returning success when asked to delete a non-existent object or an already-whiteout object that must remain a whiteout. The classic OSD returns -ENOENT in both cases. Fix both paths to return enoent, and remove the duplicate !os.exists check. Fixes: https://tracker.ceph.com/issues/76529 Signed-off-by: Ronen Friedman --- src/crimson/osd/pg_backend.cc | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/crimson/osd/pg_backend.cc b/src/crimson/osd/pg_backend.cc index b8971b2be2b..6a2e57d0992 100644 --- a/src/crimson/osd/pg_backend.cc +++ b/src/crimson/osd/pg_backend.cc @@ -1021,17 +1021,13 @@ PGBackend::remove(ObjectState& os, ceph::os::Transaction& txn, bool whiteout, int num_bytes) { - if (!os.exists) { - return crimson::ct_error::enoent::make(); - } - if (!os.exists) { logger().debug("{} {} does not exist",__func__, os.oi.soid); - return seastar::now(); + return crimson::ct_error::enoent::make(); } if (whiteout && os.oi.is_whiteout()) { logger().debug("{} whiteout set on {} ",__func__, os.oi.soid); - return seastar::now(); + return crimson::ct_error::enoent::make(); } txn.remove(coll->get_cid(), ghobject_t{os.oi.soid, ghobject_t::NO_GEN, get_shard()}); From b126cffe85e95d94e7e048bd9e51a97ec8840938 Mon Sep 17 00:00:00 2001 From: ShreeJejurikar Date: Thu, 11 Jun 2026 16:39:48 +0530 Subject: [PATCH 191/596] rgw: emit bucket logging record for lifecycle multipart upload abort Signed-off-by: ShreeJejurikar --- src/rgw/rgw_lc.cc | 14 +++-- .../rgw/bucket_logging/test_bucket_logging.py | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/rgw/rgw_lc.cc b/src/rgw/rgw_lc.cc index 8b6adba2b8c..941c955e4b6 100644 --- a/src/rgw/rgw_lc.cc +++ b/src/rgw/rgw_lc.cc @@ -707,8 +707,9 @@ static void send_notification(const DoutPrefixProvider* dpp, } } -// Op name used in journal records for LC-driven deletions. +// Op names used in records for LC-driven actions. static const std::string lifecycle_delete_object_op = "LIFECYCLE.DELETE.OBJECT"; +static const std::string lifecycle_delete_upload_op = "LIFECYCLE.DELETE.UPLOAD"; // LC has no req_state, so the source bucket's owner stands in as the // identity for the log-bucket write permission check. @@ -720,7 +721,8 @@ static void send_log_record(const DoutPrefixProvider* dpp, const std::string& etag, uint64_t size, const std::string& version_id, - const std::string& op_name) { + const std::string& op_name, + rgw::bucketlogging::LoggingType logging_type) { rgw::bucketlogging::record_input input; input.bucket = bucket; input.user_or_account = to_string(bucket->get_owner()); @@ -729,7 +731,7 @@ static void send_log_record(const DoutPrefixProvider* dpp, const int ret = rgw::bucketlogging::log_record( driver, - rgw::bucketlogging::LoggingType::Journal, + logging_type, obj, input, op_name, etag, size, dpp, y, /*async_completion=*/true, /*log_source_bucket=*/false); @@ -836,7 +838,8 @@ static int remove_expired_obj(const DoutPrefixProvider* dpp, } if (log_op_name) { send_log_record(dpp, y, driver, obj.get(), oc.bucket, etag, size, - version_id, *log_op_name); + version_id, *log_op_name, + rgw::bucketlogging::LoggingType::Journal); } } @@ -1001,6 +1004,9 @@ int RGWLC::handle_multipart_expiration(rgw::sal::Bucket* target, const auto event_type = rgw::notify::ObjectExpirationAbortMPU; send_notification(this, y, driver, sal_obj.get(), target, etag, size, obj.key.instance, {event_type}); + send_log_record(this, y, driver, sal_obj.get(), target, etag, size, + obj.key.instance, lifecycle_delete_upload_op, + rgw::bucketlogging::LoggingType::Standard); if (perfcounter) { perfcounter->inc(l_rgw_lc_abort_mpu, 1); } diff --git a/src/test/rgw/bucket_logging/test_bucket_logging.py b/src/test/rgw/bucket_logging/test_bucket_logging.py index 9e87c30e86c..f798ccc72ca 100644 --- a/src/test/rgw/bucket_logging/test_bucket_logging.py +++ b/src/test/rgw/bucket_logging/test_bucket_logging.py @@ -381,6 +381,28 @@ def read_journal_records(s3_client, log_bucket, source_bucket, prefix=None, sett return records +def wait_for_mpu_gone(s3_client, bucket, upload_id, timeout=30, interval=1): + deadline = time.time() + timeout + while time.time() < deadline: + resp = s3_client.list_multipart_uploads(Bucket=bucket) + if not any(u.get('UploadId') == upload_id for u in resp.get('Uploads', [])): + return True + time.sleep(interval) + return False + + +def abort_pending_mpus(s3_client, bucket): + try: + resp = s3_client.list_multipart_uploads(Bucket=bucket) + except ClientError: + return + for u in resp.get('Uploads', []): + try: + s3_client.abort_multipart_upload(Bucket=bucket, Key=u['Key'], UploadId=u['UploadId']) + except ClientError: + pass + + @pytest.fixture def lc_fast(): set_lc_debug_interval(5) @@ -1015,6 +1037,46 @@ def test_lc_current_expiration_dm_branch_does_not_log(s3_client, logging_type, l cleanup_bucket(s3_client, log_bucket) +@pytest.mark.basic_test +def test_lc_abort_mpu_logs_standard_record(s3_client, logging_type, lc_fast): + """LC AbortIncompleteMultipartUpload emits a Standard-mode LIFECYCLE.DELETE.UPLOAD record.""" + if logging_type != 'Standard': + pytest.skip("AbortIncompleteMultipartUpload logging is Standard-mode-only") + source = gen_bucket_name("lc-mpu-source") + log_bucket = gen_bucket_name("lc-mpu-log") + key = 'incomplete-mpu.bin' + + try: + assert create_bucket_with_logging(s3_client, source, log_bucket) + apply_lc_config(s3_client, source, [ + make_lc_rule(AbortIncompleteMultipartUpload={'DaysAfterInitiation': 1}) + ]) + + upload_id = s3_client.create_multipart_upload(Bucket=source, Key=key)['UploadId'] + + time.sleep(lc_fast + 2) + trigger_lc_processing() + assert wait_for_mpu_gone(s3_client, source, upload_id), "LC did not abort the incomplete multipart upload" + + time.sleep(5) + admin(['bucket', 'logging', 'flush', '--bucket', source]) + resp = s3_client.list_objects_v2(Bucket=log_bucket, Prefix=f'{source}/') + log_keys = [obj['Key'] for obj in resp.get('Contents', [])] + assert log_keys, "no log object emitted for MPU abort" + + bodies = [] + for log_key in log_keys: + body = s3_client.get_object(Bucket=log_bucket, Key=log_key)['Body'].read().decode('utf-8') + bodies.append(body) + joined = '\n'.join(bodies) + assert 'LIFECYCLE.DELETE.UPLOAD' in joined, f"LIFECYCLE.DELETE.UPLOAD not found in log: {joined!r}" + + finally: + abort_pending_mpus(s3_client, source) + cleanup_bucket(s3_client, source) + cleanup_bucket(s3_client, log_bucket) + + @pytest.mark.basic_test def test_lc_runs_safely_without_logging_config(s3_client, lc_fast): """LC runs successfully on a bucket without bucket-logging configured.""" From 6761549c5a719f541a850f243318a1212b76c0a6 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Wed, 6 May 2026 20:41:33 +0530 Subject: [PATCH 192/596] mgr/DaemonServer: Aggregate and globally sort OSDs for ok-to-upgrade The 'ok-to-upgrade' command output sorting did not scale accurately when target CRUSH buckets contained multiple child buckets (e.g., a chassis containing multiple hosts). OSDs were previously sorted individually per child bucket and appended sequentially. This created fragmented, per-host sort segments rather than a globally sorted list for the parent bucket. Changes: 1. Fix the issue above by aggregating all child OSDs into a single vector prior to executing a single, global sort operation based on PG counts. Additionally, optimize memory efficiency and future-proof the logic by reserving continuous vector blocks to avoid dynamic heap reallocations. 2. Add integration tests with chassis and rack based CRUSH hierarchies which verifies the ok-to-upgrade functionality. In addition, the tests crucially verify the order of OSDs returned is according to the ascending order of acting PG count. Additionally, make minor fix-ups to lines that determine the length of a list in JSON response by removing the redundant "| bc". Fixes: https://tracker.ceph.com/issues/77272 Signed-off-by: Sridhar Seshasayee --- qa/standalone/misc/ok-to-upgrade.sh | 436 ++++++++++++++++++++++++++-- src/mgr/DaemonServer.cc | 47 ++- 2 files changed, 423 insertions(+), 60 deletions(-) diff --git a/qa/standalone/misc/ok-to-upgrade.sh b/qa/standalone/misc/ok-to-upgrade.sh index 5be0eee49fb..2bdb5d7497b 100755 --- a/qa/standalone/misc/ok-to-upgrade.sh +++ b/qa/standalone/misc/ok-to-upgrade.sh @@ -35,6 +35,33 @@ function run() { done } +# Populate the global pg_count associative array with each OSD's acting +# PG count from 'ceph osd df'. +function snapshot_osd_pg_counts() { + declare -gA pg_count + pg_count=() + while read -r osd_id count; do + pg_count[$osd_id]=$count + done < <(ceph osd df --format=json | \ + jq -r '.nodes[] | select(.id >= 0) | "\(.id) \(.pgs)"') +} + +# Verify that the named OSD list in a JSON result is ordered in non-decreasing +# acting PG count. Uses the global pg_count array populated by +# snapshot_osd_pg_counts(). +# Usage: assert_osds_sorted_by_pg_count +function assert_osds_sorted_by_pg_count() { + local field=$1 + local json=$2 + local prev_count=-1 + local osd_id count + for osd_id in $(echo "$json" | jq -r ".${field}[]"); do + count=${pg_count[$osd_id]} + test "$count" -ge "$prev_count" || return 1 + prev_count=$count + done +} + function TEST_ok_to_upgrade_invalid_args() { local dir=$1 CEPH_ARGS="$ORIG_CEPH_ARGS --mon-host=$CEPH_MON " @@ -103,9 +130,9 @@ function TEST_ok_to_upgrade_replicated_pool() { # and verifies that at least the expected minimum OSDs are returned. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -ge $exp_osds_upgradable || return 1 - local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test the same command as above, but exercise the 'max' parameter. @@ -116,9 +143,9 @@ function TEST_ok_to_upgrade_replicated_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version $max --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq $exp_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test same command above with verbose syntax @@ -126,9 +153,9 @@ function TEST_ok_to_upgrade_replicated_pool() { --ceph_version $ceph_version --max $max --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq $exp_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=1 and 1 OSD to upgrade. The outcome @@ -139,9 +166,9 @@ function TEST_ok_to_upgrade_replicated_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=2 @@ -154,9 +181,9 @@ function TEST_ok_to_upgrade_replicated_pool() { # 3 OSDs should be reported as upgradable. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -ge $exp_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=3 @@ -168,9 +195,9 @@ function TEST_ok_to_upgrade_replicated_pool() { # No OSD should be reported as upgradable. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = false || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for condition when all OSDs are running desired version. @@ -179,9 +206,9 @@ function TEST_ok_to_upgrade_replicated_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $upgrade_version --format=json) test $(echo $res | jq '.all_osds_upgraded') = true || return 1 test $(echo $res | jq '.ok_to_upgrade') = false || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq 0 || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq $OSDS || return 1 } @@ -217,9 +244,9 @@ function TEST_ok_to_upgrade_erasure_pool() { # in 3 OSDs being reported as upgradable. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test the same command as above, but exercise the 'max' parameter. @@ -230,9 +257,9 @@ function TEST_ok_to_upgrade_erasure_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version $max --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq $exp_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test command above with verbose syntax @@ -240,9 +267,9 @@ function TEST_ok_to_upgrade_erasure_pool() { --ceph_version $ceph_version --max $max --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq $exp_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=5 and 1 OSD to upgrade. The outcome @@ -253,9 +280,9 @@ function TEST_ok_to_upgrade_erasure_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=6 @@ -268,9 +295,9 @@ function TEST_ok_to_upgrade_erasure_pool() { # 2 OSDs should be reported as upgradable. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = true || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for upgradability with min_size=8 @@ -282,9 +309,9 @@ function TEST_ok_to_upgrade_erasure_pool() { # No OSD should be reported as upgradable. test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = false || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq 0 || return 1 # Test for condition when all OSDs are running desired version. @@ -293,9 +320,9 @@ function TEST_ok_to_upgrade_erasure_pool() { res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version --format=json) test $(echo $res | jq '.all_osds_upgraded') = true || return 1 test $(echo $res | jq '.ok_to_upgrade') = false || return 1 - num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $num_osds_upgradable -eq 0 || return 1 - num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length' | bc) + num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') test $num_osds_upgraded -eq $OSDS || return 1 } @@ -331,11 +358,360 @@ function TEST_ok_to_upgrade_bad_osd_version() { local res=$(ceph osd ok-to-upgrade $crush_bucket $ceph_version --format=json) test $(echo $res | jq '.all_osds_upgraded') = false || return 1 test $(echo $res | jq '.ok_to_upgrade') = false || return 1 - local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length' | bc) + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') test $exp_osds_upgradable = $num_osds_upgradable || return 1 - local num_osds_bad_version=$(echo $res | jq '.bad_no_version | length' | bc) + local num_osds_bad_version=$(echo $res | jq '.bad_no_version | length') test $num_osds_bad_version -eq 3 || return 1 } +function TEST_ok_to_upgrade_chassis_bucket() { + local dir=$1 + local poolname="test" + local OSDS=4 + local ceph_version="01.2.3-1234-g1234deed" + + CEPH_ARGS="$ORIG_CEPH_ARGS --mon-host=$CEPH_MON " + + run_mon $dir a --public-addr=$CEPH_MON || return 1 + run_mgr $dir x || return 1 + + for osd in $(seq 0 $(expr $OSDS - 1)); do + run_osd $dir $osd --osd-mclock-skip-benchmark=true || return 1 + done + + # Use size=2 so the pool can be satisfied with 2 distinct host buckets. + # min_size=1 allows upgrading all OSDs on one host simultaneously. + create_pool $poolname 8 8 + ceph osd pool set $poolname pg_autoscale_mode off + ceph osd pool set $poolname size 2 + ceph osd pool set $poolname min_size 1 + sleep 5 + wait_for_clean || return 1 + + # Build a chassis CRUSH hierarchy: + # root "default" + # chassis "chassis-0" + # host "host-0-0" -> osd.0, osd.1 + # host "host-0-1" -> osd.2, osd.3 + # + # OSDs start in auto-generated host buckets; crush set replaces each + # OSD's location with the new custom hierarchy. + ceph osd crush add-bucket chassis-0 chassis || return 1 + ceph osd crush add-bucket host-0-0 host || return 1 + ceph osd crush add-bucket host-0-1 host || return 1 + ceph osd crush move host-0-0 chassis=chassis-0 || return 1 + ceph osd crush move host-0-1 chassis=chassis-0 || return 1 + ceph osd crush move chassis-0 root=default || return 1 + ceph osd crush set 0 1.0 root=default chassis=chassis-0 host=host-0-0 || return 1 + ceph osd crush set 1 1.0 root=default chassis=chassis-0 host=host-0-0 || return 1 + ceph osd crush set 2 1.0 root=default chassis=chassis-0 host=host-0-1 || return 1 + ceph osd crush set 3 1.0 root=default chassis=chassis-0 host=host-0-1 || return 1 + sleep 5 + wait_for_clean || return 1 + + # Snapshot acting PG counts per OSD. In a clean cluster 'pgs' in + # osd df equals the acting count the mgr's PGMap records internally. + snapshot_osd_pg_counts + + # With min_size=1 some OSDs inside chassis-0 must be reported upgradable. + local res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -ge 1 || return 1 + local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') + test $num_osds_upgraded -eq 0 || return 1 + + # Verify that osds_in_crush_bucket lists all chassis OSDs in non-decreasing + # order of acting PG count. This directly tests the global sort inside + # _populate_crush_bucket_osds(): consecutive entries must never decrease. + assert_osds_sorted_by_pg_count osds_in_crush_bucket "$res" || return 1 + + # With max=1, exactly one OSD is returned. + local max=1 + res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version $max --format=json) + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 1 || return 1 + + # With min_size=2 on a size=2 pool, taking any OSD offline drops a PG + # below the minimum replica count -- no OSD should be upgradable. + ceph osd pool set $poolname min_size 2 + sleep 5 + wait_for_clean || return 1 + res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = false || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 0 || return 1 +} + +function TEST_ok_to_upgrade_rack_bucket() { + local dir=$1 + local poolname="test" + local OSDS=4 + local ceph_version="01.2.3-1234-g1234deed" + + CEPH_ARGS="$ORIG_CEPH_ARGS --mon-host=$CEPH_MON " + + run_mon $dir a --public-addr=$CEPH_MON || return 1 + run_mgr $dir x || return 1 + + for osd in $(seq 0 $(expr $OSDS - 1)); do + run_osd $dir $osd --osd-mclock-skip-benchmark=true || return 1 + done + + # Use size=2 so the pool can be satisfied with 2 distinct host buckets. + # min_size=1 allows upgrading all OSDs on one host simultaneously. + create_pool $poolname 8 8 + ceph osd pool set $poolname pg_autoscale_mode off + ceph osd pool set $poolname size 2 + ceph osd pool set $poolname min_size 1 + sleep 5 + wait_for_clean || return 1 + + # Build a rack CRUSH hierarchy: + # root "default" + # rack "rack-0" + # host "rack-host-0" -> osd.0, osd.1 + # host "rack-host-1" -> osd.2, osd.3 + ceph osd crush add-bucket rack-0 rack || return 1 + ceph osd crush add-bucket rack-host-0 host || return 1 + ceph osd crush add-bucket rack-host-1 host || return 1 + ceph osd crush move rack-host-0 rack=rack-0 || return 1 + ceph osd crush move rack-host-1 rack=rack-0 || return 1 + ceph osd crush move rack-0 root=default || return 1 + ceph osd crush set 0 1.0 root=default rack=rack-0 host=rack-host-0 || return 1 + ceph osd crush set 1 1.0 root=default rack=rack-0 host=rack-host-0 || return 1 + ceph osd crush set 2 1.0 root=default rack=rack-0 host=rack-host-1 || return 1 + ceph osd crush set 3 1.0 root=default rack=rack-0 host=rack-host-1 || return 1 + sleep 5 + wait_for_clean || return 1 + + # Snapshot acting PG counts per OSD. In a clean cluster 'pgs' in + # osd df equals the acting count the mgr's PGMap records internally. + snapshot_osd_pg_counts + + # With min_size=1 some OSDs inside rack-0 must be reported upgradable. + local res=$(ceph osd ok-to-upgrade rack-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -ge 1 || return 1 + local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') + test $num_osds_upgraded -eq 0 || return 1 + + # Verify that osds_in_crush_bucket lists all rack OSDs in non-decreasing + # order of acting PG count. This directly tests the global sort inside + # _populate_crush_bucket_osds(): consecutive entries must never decrease. + assert_osds_sorted_by_pg_count osds_in_crush_bucket "$res" || return 1 + + # With max=1, exactly one OSD is returned. + local max=1 + res=$(ceph osd ok-to-upgrade rack-0 $ceph_version $max --format=json) + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 1 || return 1 + + # With min_size=2 on a size=2 pool, no OSD can go offline without + # violating the pool's minimum replica count. + ceph osd pool set $poolname min_size 2 + sleep 5 + wait_for_clean || return 1 + res=$(ceph osd ok-to-upgrade rack-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = false || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 0 || return 1 +} + +function TEST_ok_to_upgrade_rack_chassis_bucket() { + local dir=$1 + local poolname="test" + local OSDS=4 + local ceph_version="01.2.3-1234-g1234deed" + + CEPH_ARGS="$ORIG_CEPH_ARGS --mon-host=$CEPH_MON " + + run_mon $dir a --public-addr=$CEPH_MON || return 1 + run_mgr $dir x || return 1 + + for osd in $(seq 0 $(expr $OSDS - 1)); do + run_osd $dir $osd --osd-mclock-skip-benchmark=true || return 1 + done + + # Use size=2 so the pool can be satisfied with 2 distinct host buckets. + # min_size=1 allows upgrading all OSDs on one host simultaneously. + create_pool $poolname 8 8 + ceph osd pool set $poolname pg_autoscale_mode off + ceph osd pool set $poolname size 2 + ceph osd pool set $poolname min_size 1 + sleep 5 + wait_for_clean || return 1 + + # Build a rack CRUSH hierarchy: + # root "default" + # rack "rack-0" + # chassis "chassis-0" + # host "chassis-host-0" -> osd.0, osd.1 + # host "chassis-host-1" -> osd.2, osd.3 + ceph osd crush add-bucket rack-0 rack || return 1 + ceph osd crush add-bucket chassis-0 chassis || return 1 + ceph osd crush add-bucket chassis-host-0 host || return 1 + ceph osd crush add-bucket chassis-host-1 host || return 1 + ceph osd crush move chassis-host-0 chassis=chassis-0 || return 1 + ceph osd crush move chassis-host-1 chassis=chassis-0 || return 1 + ceph osd crush move chassis-0 rack=rack-0 || return 1 + ceph osd crush move rack-0 root=default || return 1 + ceph osd crush set 0 1.0 root=default rack=rack-0 host=chassis-host-0 || return 1 + ceph osd crush set 1 1.0 root=default rack=rack-0 host=chassis-host-0 || return 1 + ceph osd crush set 2 1.0 root=default rack=rack-0 host=chassis-host-1 || return 1 + ceph osd crush set 3 1.0 root=default rack=rack-0 host=chassis-host-1 || return 1 + sleep 5 + wait_for_clean || return 1 + + # Snapshot acting PG counts per OSD. In a clean cluster 'pgs' in + # osd df equals the acting count the mgr's PGMap records internally. + snapshot_osd_pg_counts + + # With min_size=1 some OSDs inside chassis-0 must be reported upgradable. + local res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + local num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -ge 1 || return 1 + local num_osds_upgraded=$(echo $res | jq '.osds_upgraded | length') + test $num_osds_upgraded -eq 0 || return 1 + + # Verify that osds_in_crush_bucket lists all chassis OSDs in non-decreasing + # order of acting PG count. This directly tests the global sort inside + # _populate_crush_bucket_osds(): consecutive entries must never decrease. + assert_osds_sorted_by_pg_count osds_in_crush_bucket "$res" || return 1 + + # With max=1, exactly one OSD is returned. + local max=1 + res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version $max --format=json) + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 1 || return 1 + + # With min_size=2 on a size=2 pool, no OSD can go offline without + # violating the pool's minimum replica count. + ceph osd pool set $poolname min_size 2 + sleep 5 + wait_for_clean || return 1 + res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = false || return 1 + num_osds_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_osds_upgradable -eq 0 || return 1 +} + +# Verify that ok-to-upgrade returns OSDs in ascending order of acting PG +# count globally across the entire chassis (not per-host). +# +# _populate_crush_bucket_osds collects OSDs from every child host bucket, +# then sorts them once globally by (num_pgs, osd_id) ascending before +# returning to _maximize_ok_to_upgrade_set. The convergence algorithm +# prunes from the tail (osds.resize), preserving this global order in the +# final osds_ok_to_upgrade output. +# +# Test design +# ----------- +# 6 OSDs in a chassis (3 per host), pool with 7 PGs and size=2: CRUSH +# maps each PG to one OSD per host. 7 % 3 != 0 produces an unequal +# per-OSD acting count across the chassis, making the global ordering +# non-trivial to predict without actually measuring it. Using 7 (rather +# than a larger number such as 16) widens the relative spread between +# OSD PG counts, reducing ties and making the ordering invariant more +# discriminating. +# +# The test snapshots the acting PG count per OSD from 'ceph osd df', then +# checks two invariants: +# 1. The returned list is non-decreasing by acting PG count (global sort). +# 2. The first returned OSD holds the minimum acting PG count among all +# OSDs in chassis-0, confirming that the globally-least-loaded OSD +# is always offered for upgrade first. +function TEST_ok_to_upgrade_osd_order_by_pg_count() { + local dir=$1 + local poolname="test" + local OSDS=6 + local ALL_CHASSIS_OSDS="0 1 2 3 4 5" + local ceph_version="01.2.3-1234-g1234deed" + + CEPH_ARGS="$ORIG_CEPH_ARGS --mon-host=$CEPH_MON " + + run_mon $dir a --public-addr=$CEPH_MON || return 1 + run_mgr $dir x || return 1 + + for osd in $(seq 0 $(expr $OSDS - 1)); do + run_osd $dir $osd --osd-mclock-skip-benchmark=true || return 1 + done + + # 7 PGs, size=2, min_size=1. 7 % 3 != 0 guarantees an unequal + # per-OSD acting count within the chassis. + create_pool $poolname 7 7 + ceph osd pool set $poolname pg_autoscale_mode off + ceph osd pool set $poolname size 2 + ceph osd pool set $poolname min_size 1 + sleep 5 + wait_for_clean || return 1 + + # Build a chassis hierarchy: + # root "default" + # chassis "chassis-0" + # host "host-0-0" -> osd.0, osd.1, osd.2 + # host "host-0-1" -> osd.3, osd.4, osd.5 + ceph osd crush add-bucket chassis-0 chassis || return 1 + ceph osd crush add-bucket host-0-0 host || return 1 + ceph osd crush add-bucket host-0-1 host || return 1 + ceph osd crush move host-0-0 chassis=chassis-0 || return 1 + ceph osd crush move host-0-1 chassis=chassis-0 || return 1 + ceph osd crush move chassis-0 root=default || return 1 + ceph osd crush set 0 1.0 root=default chassis=chassis-0 host=host-0-0 || return 1 + ceph osd crush set 1 1.0 root=default chassis=chassis-0 host=host-0-0 || return 1 + ceph osd crush set 2 1.0 root=default chassis=chassis-0 host=host-0-0 || return 1 + ceph osd crush set 3 1.0 root=default chassis=chassis-0 host=host-0-1 || return 1 + ceph osd crush set 4 1.0 root=default chassis=chassis-0 host=host-0-1 || return 1 + ceph osd crush set 5 1.0 root=default chassis=chassis-0 host=host-0-1 || return 1 + sleep 5 + wait_for_clean || return 1 + + # Snapshot acting PG counts per OSD. In a clean cluster 'pgs' in + # osd df equals the acting count the mgr's PGMap records internally. + snapshot_osd_pg_counts + + local res=$(ceph osd ok-to-upgrade chassis-0 $ceph_version --format=json) + test $(echo $res | jq '.all_osds_upgraded') = false || return 1 + test $(echo $res | jq '.ok_to_upgrade') = true || return 1 + local num_upgradable=$(echo $res | jq '.osds_ok_to_upgrade | length') + test $num_upgradable -ge 1 || return 1 + + # Verify that osds_in_crush_bucket lists all chassis OSDs in non-decreasing + # order of acting PG count. osds_in_crush_bucket is the direct output of + # _populate_crush_bucket_osds() before the convergence algorithm runs, so + # this check covers every OSD in the bucket, not just the upgrade subset. + assert_osds_sorted_by_pg_count osds_in_crush_bucket "$res" || return 1 + + # Invariant 1: the returned OSD list must be globally non-decreasing by + # acting PG count. _populate_crush_bucket_osds sorts all chassis OSDs + # together; the convergence algorithm only prunes from the tail, so the + # ascending order is always preserved in the output. + local prev_count=-1 + for osd_id in $(echo $res | jq -r '.osds_ok_to_upgrade[]'); do + local count=${pg_count[$osd_id]} + test "$count" -ge "$prev_count" || return 1 + prev_count=$count + done + + # Invariant 2: the first OSD in the list must have the globally minimum + # acting PG count across all OSDs in chassis-0. This confirms that the + # globally-least-loaded OSD is always offered for upgrade first. + local first_osd=$(echo $res | jq -r '.osds_ok_to_upgrade[0]') + local first_count=${pg_count[$first_osd]} + for osd_id in $ALL_CHASSIS_OSDS; do + test "${pg_count[$osd_id]}" -ge "$first_count" || return 1 + done +} main ok-to-upgrade "$@" diff --git a/src/mgr/DaemonServer.cc b/src/mgr/DaemonServer.cc index afe0a0c37fa..a7ddac0868d 100644 --- a/src/mgr/DaemonServer.cc +++ b/src/mgr/DaemonServer.cc @@ -1276,6 +1276,7 @@ int DaemonServer::_populate_crush_bucket_osds( } else if (bucket_type_str == "host" || bucket_type_str == "osd") { bucket_names.push_back(item_name); } + // The following struct is to help re-order the // osds based on the number of pgs on them. struct pgs_per_osd { @@ -1283,10 +1284,8 @@ int DaemonServer::_populate_crush_bucket_osds( size_t num_pgs; }; std::vector child_bucket_pgs_per_osd; - // get osds under each child bucket + // get osds under each child bucket and associate with their PG counts for (const auto &name : bucket_names) { - // Clear the items for the current child bucket - child_bucket_pgs_per_osd.clear(); std::set tmp_bucket_osds; r = osdmap.get_osds_by_bucket_name(name, &tmp_bucket_osds); if (r < 0) { @@ -1300,39 +1299,27 @@ int DaemonServer::_populate_crush_bucket_osds( dout(20) << os.str() << dendl; return r; } - - // Special case when bucket contains only 1 osd - if (tmp_bucket_osds.size() == 1) { - for (const auto &osd : tmp_bucket_osds) { - crush_bucket_osds.push_back(osd); - } - dout(20) << "picked osd: " << tmp_bucket_osds - << " from bucket: " << name << dendl; - continue; - } - /** - * The osds in this bucket are further re-ordered based on the - * number of pgs (ascending) they host. This helps optimize - * the result of _check_offlines_pgs() down the line. - */ for (const auto &osd : tmp_bucket_osds) { child_bucket_pgs_per_osd.push_back({osd, pgmap.get_num_pg_by_osd(osd)}); } - // Sort once after all data is added - std::sort(child_bucket_pgs_per_osd.begin(), child_bucket_pgs_per_osd.end(), - [](const pgs_per_osd& a, const pgs_per_osd& b) { - return std::tie(a.num_pgs, a.osd_id) < std::tie(b.num_pgs, b.osd_id); - }); - /** - * The sorted osds are finally pushed to the passed crush_bucket_osds - * vector where osds are maintained according to the child order. - */ - for (const auto &item : child_bucket_pgs_per_osd) { - crush_bucket_osds.push_back(item.osd_id); - } dout(20) << "picked osds: " << tmp_bucket_osds << " from bucket: " << name << dendl; } + + /** + * Sort all collected osds globally based on the number of pgs (ascending) + * they host and update the crush_bucket_osds vector with the same order. + */ + std::sort(child_bucket_pgs_per_osd.begin(), child_bucket_pgs_per_osd.end(), + [](const pgs_per_osd& a, const pgs_per_osd& b) { + return std::tie(a.num_pgs, a.osd_id) < std::tie(b.num_pgs, b.osd_id); + }); + crush_bucket_osds.reserve( + crush_bucket_osds.size() + child_bucket_pgs_per_osd.size()); + for (const auto &item : child_bucket_pgs_per_osd) { + crush_bucket_osds.push_back(item.osd_id); + } + return r; } From d27261d0c2641e92365cf590684567c58bbfb905 Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Thu, 11 Jun 2026 14:42:57 -0400 Subject: [PATCH 193/596] Reapply "qa/rgw/crypt: disable failing kmip testing" This reverts commit fd2046798198db20e45b8abbb3cc866c9967fb88. kmip tests are failing again on ubuntu 24 because PyKMIP doesn't support python 3.12. we'll be removing ubuntu 22 from main, so can't just pin the test to that distro in the meantime we're expecing the nvmeof team to add python 3.12 support in our ceph/PyKMIP fork, and can reenable kmip testing once that happens Fixes: https://tracker.ceph.com/issues/76995 Signed-off-by: Casey Bodley --- qa/suites/rgw/crypt/2-kms/kmip.yaml | 37 ----------------------------- 1 file changed, 37 deletions(-) delete mode 100644 qa/suites/rgw/crypt/2-kms/kmip.yaml diff --git a/qa/suites/rgw/crypt/2-kms/kmip.yaml b/qa/suites/rgw/crypt/2-kms/kmip.yaml deleted file mode 100644 index 0057d954e32..00000000000 --- a/qa/suites/rgw/crypt/2-kms/kmip.yaml +++ /dev/null @@ -1,37 +0,0 @@ -overrides: - ceph: - conf: - client: - rgw crypt s3 kms backend: kmip - rgw crypt kmip ca path: /etc/ceph/kmiproot.crt - rgw crypt kmip client cert: /etc/ceph/kmip-client.crt - rgw crypt kmip client key: /etc/ceph/kmip-client.key - rgw crypt kmip kms key template: pykmip-$keyid - rgw: - client.0: - use-pykmip-role: client.0 - -tasks: -- openssl_keys: - kmiproot: - client: client.0 - cn: kmiproot - key-type: rsa:4096 - kmip-server: - client: client.0 - ca: kmiproot - kmip-client: - client: client.0 - ca: kmiproot - cn: rgw-client -- exec: - client.0: - - chmod 644 /home/ubuntu/cephtest/ca/kmip-client.key -- pykmip: - client.0: - clientca: kmiproot - servercert: kmip-server - clientcert: kmip-client - secrets: - - name: pykmip-my-key-1 - - name: pykmip-my-key-2 From 57d34f8e19bfe6cc83ef34be3d386586ccba0061 Mon Sep 17 00:00:00 2001 From: Gregory Farnum Date: Thu, 11 Jun 2026 11:50:16 -0700 Subject: [PATCH 194/596] libcephfs: increment extra version to indicate new flock functions Signed-off-by: Greg Farnum Signed-off-by: Gregory Farnum --- src/include/cephfs/libcephfs.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/include/cephfs/libcephfs.h b/src/include/cephfs/libcephfs.h index 2372e92eb2f..15bc0140043 100644 --- a/src/include/cephfs/libcephfs.h +++ b/src/include/cephfs/libcephfs.h @@ -42,7 +42,7 @@ extern "C" { #define LIBCEPHFS_VER_MAJOR 11 #define LIBCEPHFS_VER_MINOR 0 -#define LIBCEPHFS_VER_EXTRA 0 +#define LIBCEPHFS_VER_EXTRA 1 #define LIBCEPHFS_VERSION(maj, min, extra) ((maj << 16) + (min << 8) + extra) #define LIBCEPHFS_VERSION_CODE LIBCEPHFS_VERSION(LIBCEPHFS_VER_MAJOR, LIBCEPHFS_VER_MINOR, LIBCEPHFS_VER_EXTRA) From fbfe653454c3bc7a485cb047a5f853b18f2667cc Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 11 Jun 2026 11:09:50 +0530 Subject: [PATCH 195/596] ceph.spec.in: disable -P in python shebang for cephadm binary Fixes: https://tracker.ceph.com/issues/77340 Signed-off-by: Shweta Bhosale --- ceph.spec.in | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ceph.spec.in b/ceph.spec.in index 0ec85c99755..51366d43b5d 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -1993,6 +1993,9 @@ install -m 0644 -D udev/50-rbd.rules %{buildroot}%{_udevrulesdir}/50-rbd.rules install -m 0440 -D sudoers.d/ceph-smartctl %{buildroot}%{_sysconfdir}/sudoers.d/ceph-smartctl %if 0%{?rhel} >= 8 || 0%{?openEuler} +# Undefine -P flag as it is only supported with python version >= 3.11 +%undefine _py3_shebang_P + %{py3_shebang_fix} %{buildroot}%{_bindir}/* %{buildroot}%{_sbindir}/* %endif From 0347cc859157c0a6057c80a5db55ed706e898bf8 Mon Sep 17 00:00:00 2001 From: Kobi Ginon Date: Tue, 19 May 2026 18:54:37 +0300 Subject: [PATCH 196/596] cephadm: address Copilot review on list-networks BGP parsing Skip default routes in ip -j BGP JSON parsing, fix black line-length in loopback check, and update list-networks CLI help for --allow-lo-routes and --allow-bgp-routes. Signed-off-by: Kobi Ginon --- src/cephadm/cephadm.py | 4 +- src/cephadm/cephadmlib/host_facts.py | 145 ++++++++++--------- src/cephadm/tests/test_networks.py | 22 +-- src/cephadm/tests/test_networks_lo_routes.py | 37 +++++ 4 files changed, 115 insertions(+), 93 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index b3133088961..ba990aed6ad 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -5023,13 +5023,13 @@ def _get_parser(): '--allow-lo-routes', action='store_true', default=False, - help='Reserved for future filtering of loopback-related routes', + help='Include loopback (lo) routes in the listing (default: omit)', ) parser_list_networks.add_argument( '--allow-bgp-routes', action='store_true', default=False, - help='Reserved for future filtering of BGP-derived routes', + help='Merge BGP routes from ip -j route ls proto bgp (default: omit)', ) parser_list_networks.set_defaults(func=command_list_networks) diff --git a/src/cephadm/cephadmlib/host_facts.py b/src/cephadm/cephadmlib/host_facts.py index c4acd4a6b69..c169632b072 100644 --- a/src/cephadm/cephadmlib/host_facts.py +++ b/src/cephadm/cephadmlib/host_facts.py @@ -909,45 +909,37 @@ def list_rdma(ctx: CephadmContext) -> List[Dict[str, str]]: return result -_IPV4_LOOPBACK_SPACE = ipaddress.ip_network('127.0.0.0/8') -_IPV6_LOOPBACK_HOST = ipaddress.ip_network('::1/128') +def _list_ipv4_networks( + ctx: CephadmContext, + allow_lo_routes: bool = False, + allow_bgp_routes: bool = False, +) -> Dict[str, Dict[str, Set[str]]]: + """IPv4 networks from ``ip route ls`` (and optional ``proto bgp`` supplement). - -def _is_ipv4_loopback(net_s: str) -> bool: - """True if *net_s* is an IPv4 prefix wholly within host loopback 127.0.0.0/8.""" - try: - n = ipaddress.ip_network(net_s, strict=False) - except ValueError: - return False - return ( - n.version == 4 - and n.network_address in _IPV4_LOOPBACK_SPACE - and n.broadcast_address in _IPV4_LOOPBACK_SPACE + When *allow_lo_routes* is false, routes on ``lo`` are ignored by the parsers. + When true, ``_parse_ipv4_lo_route`` supplements the base table. + """ + execstr: Optional[str] = find_executable('ip') + if not execstr: + raise FileNotFoundError("unable to find 'ip' command") + out, _, _ = call_throws( + ctx, + [execstr, 'route', 'ls'], + verbosity=CallVerbosity.QUIET_UNLESS_ERROR, ) - - -def _is_ipv6_loopback(net_s: str) -> bool: - """True if *net_s* is exactly the host loopback prefix ::1/128.""" - try: - n = ipaddress.ip_network(net_s, strict=False) - except ValueError: - return False - if n.version != 6: - return False - return n == _IPV6_LOOPBACK_HOST - - -def _merge_ipv4_network_dicts( - dst: Dict[str, Dict[str, Set[str]]], add: Dict[str, Dict[str, Set[str]]] -) -> None: - """Merge *add* into *dst* (nested sets).""" - for net, ifaces in add.items(): - if net not in dst: - dst[net] = {} - for iface, ips in ifaces.items(): - if iface not in dst[net]: - dst[net][iface] = set() - dst[net][iface].update(ips) + res = _parse_ipv4_route(out, allow_lo_routes) + if allow_lo_routes: + _merge_ipv4_network_dicts(res, _parse_ipv4_lo_route(out)) + if allow_bgp_routes: + bgp_out, _, _ = call_throws( + ctx, + [execstr, '-j', 'route', 'ls', 'proto', 'bgp'], + verbosity=CallVerbosity.QUIET_UNLESS_ERROR, + ) + _merge_ipv4_network_dicts( + res, _parse_ipv4_bgp_route(bgp_out, allow_lo_routes) + ) + return res def _parse_ipv4_route( @@ -1056,9 +1048,10 @@ def _parse_bgp_routes_json( is_v4 = version == 4 host_plen = 32 if is_v4 else 128 + is_loopback_net = _is_ipv4_loopback if is_v4 else _is_ipv6_loopback def put(net: str, iface: str, ip: str) -> None: - if _is_ipv4_loopback(net) if is_v4 else _is_ipv6_loopback(net): + if is_loopback_net(net): return if net not in r: r[net] = {} @@ -1070,7 +1063,7 @@ def _parse_bgp_routes_json( if not isinstance(route, dict): continue dst = route.get('dst') - if not dst: + if not dst or dst == 'default': continue net = _route_dst_to_network(dst, version) @@ -1127,39 +1120,6 @@ def _parse_ipv6_bgp_route( return _parse_bgp_routes_json(out, 6, allow_lo_routes) -def _list_ipv4_networks( - ctx: CephadmContext, - allow_lo_routes: bool = False, - allow_bgp_routes: bool = False, -) -> Dict[str, Dict[str, Set[str]]]: - """IPv4 networks from ``ip route ls`` (and optional ``proto bgp`` supplement). - - When *allow_lo_routes* is false, routes on ``lo`` are ignored by the parsers. - When true, ``_parse_ipv4_lo_route`` supplements the base table. - """ - execstr: Optional[str] = find_executable('ip') - if not execstr: - raise FileNotFoundError("unable to find 'ip' command") - out, _, _ = call_throws( - ctx, - [execstr, 'route', 'ls'], - verbosity=CallVerbosity.QUIET_UNLESS_ERROR, - ) - res = _parse_ipv4_route(out, allow_lo_routes) - if allow_lo_routes: - _merge_ipv4_network_dicts(res, _parse_ipv4_lo_route(out)) - if allow_bgp_routes: - bgp_out, _, _ = call_throws( - ctx, - [execstr, '-j', 'route', 'ls', 'proto', 'bgp'], - verbosity=CallVerbosity.QUIET_UNLESS_ERROR, - ) - _merge_ipv4_network_dicts( - res, _parse_ipv4_bgp_route(bgp_out, allow_lo_routes) - ) - return res - - def _list_ipv6_networks( ctx: CephadmContext, allow_lo_routes: bool = False, @@ -1247,3 +1207,44 @@ def _parse_ipv6_route( r[net[0]][iface].add(ip) return r + + +_IPV4_LOOPBACK_SPACE = ipaddress.ip_network('127.0.0.0/8') +_IPV6_LOOPBACK_HOST = ipaddress.ip_network('::1/128') + + +def _is_ipv4_loopback(net_s: str) -> bool: + """True if *net_s* is an IPv4 prefix wholly within host loopback 127.0.0.0/8.""" + try: + n = ipaddress.ip_network(net_s, strict=False) + except ValueError: + return False + return ( + n.version == 4 + and n.network_address in _IPV4_LOOPBACK_SPACE + and n.broadcast_address in _IPV4_LOOPBACK_SPACE + ) + + +def _is_ipv6_loopback(net_s: str) -> bool: + """True if *net_s* is exactly the host loopback prefix ::1/128.""" + try: + n = ipaddress.ip_network(net_s, strict=False) + except ValueError: + return False + if n.version != 6: + return False + return n == _IPV6_LOOPBACK_HOST + + +def _merge_ipv4_network_dicts( + dst: Dict[str, Dict[str, Set[str]]], add: Dict[str, Dict[str, Set[str]]] +) -> None: + """Merge *add* into *dst* (nested sets).""" + for net, ifaces in add.items(): + if net not in dst: + dst[net] = {} + for iface, ips in ifaces.items(): + if iface not in dst[net]: + dst[net][iface] = set() + dst[net][iface].update(ips) diff --git a/src/cephadm/tests/test_networks.py b/src/cephadm/tests/test_networks.py index f854dfab1fb..6401c53675e 100644 --- a/src/cephadm/tests/test_networks.py +++ b/src/cephadm/tests/test_networks.py @@ -48,8 +48,6 @@ class TestEndPoint: class TestCommandListNetworks: - # fmt: off - # Keep table-style parametrization unchanged for reviewable diffs (see PR discussion). @pytest.mark.parametrize("test_input, expected", [ ( dedent(""" @@ -108,11 +106,9 @@ class TestCommandListNetworks: } ), ]) - # fmt: on def test_parse_ipv4_route(self, test_input, expected): assert _parse_ipv4_route(test_input) == expected - # fmt: off @pytest.mark.parametrize("test_routes, test_ips, expected", [ ( dedent(""" @@ -240,8 +236,7 @@ class TestCommandListNetworks: ::1 dev lo proto kernel metric 256 pref medium fe80::/64 dev ceph-brx proto kernel metric 256 pref medium fe80::/64 dev brx.0 proto kernel metric 256 pref medium - default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium - """), + default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium """), dedent(""" 1: lo: mtu 65536 state UNKNOWN qlen 1000 inet6 ::1/128 scope host @@ -264,7 +259,6 @@ class TestCommandListNetworks: } ), ]) - # fmt: on def test_parse_ipv6_route(self, test_routes, test_ips, expected): assert _parse_ipv6_route(test_routes, test_ips) == expected @@ -311,18 +305,8 @@ fe80000000000000505400fffe04c154 03 40 20 80 eth1 @mock.patch('cephadmlib.host_facts.call_throws') @mock.patch('cephadmlib.host_facts.find_executable') - def test_command_list_networks( - self, _find_exe, _call_throws, cephadm_fs, capsys - ): - _call_throws.side_effect = [ - ( - '10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n', - '', - '', - ), - ('', '', ''), - ('', '', ''), - ] + def test_command_list_networks(self, _find_exe, _call_throws, cephadm_fs, capsys): + _call_throws.return_value = ('10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n', '', '') _find_exe.return_value = 'ip' with with_cephadm_ctx([]) as ctx: _cephadm.command_list_networks(ctx) diff --git a/src/cephadm/tests/test_networks_lo_routes.py b/src/cephadm/tests/test_networks_lo_routes.py index 4e02c972e35..4ce5fa75b3d 100644 --- a/src/cephadm/tests/test_networks_lo_routes.py +++ b/src/cephadm/tests/test_networks_lo_routes.py @@ -253,6 +253,43 @@ class TestLoopbackRouteParsing: '10.168.100.10/32': {'lo': {'10.168.100.10'}}, } + def test_parse_ipv4_bgp_route_skips_default(self): + bgp_input = json.dumps( + [ + { + 'dst': 'default', + 'protocol': 'bgp', + 'prefsrc': '192.168.100.11', + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + { + 'dst': '192.168.100.5', + 'protocol': 'bgp', + 'prefsrc': '192.168.100.11', + 'flags': [], + 'nexthops': [ + { + 'gateway': '169.254.0.1', + 'dev': 'ens1f0np0', + 'weight': 1, + 'flags': ['onlink'], + }, + ], + }, + ] + ) + assert _parse_ipv4_bgp_route(bgp_input) == { + '192.168.100.5/32': {'ens1f0np0': {'192.168.100.11'}}, + } + def test_parse_ipv4_bgp_route_lo_from_full_table(self): # ``ip -j route ls`` entry for ``10.10.10.10 dev lo proto bgp``. bgp_input = json.dumps( From 0b84dbe7013119295a720ee72e82f85e52764ec7 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Tue, 22 Jul 2025 13:13:55 +0530 Subject: [PATCH 197/596] src/common/mclock_common: Fix output formatting of SchedulerClass The earlier output formatting was resulting in the value and string representation of the SchedulerClass being clubbed together for e.g., "3client" The formatting is now fixed to log SchedulerClass as "3 (client)". Signed-off-by: Sridhar Seshasayee --- src/common/mclock_common.cc | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/common/mclock_common.cc b/src/common/mclock_common.cc index db0651232d6..616681018f7 100644 --- a/src/common/mclock_common.cc +++ b/src/common/mclock_common.cc @@ -34,23 +34,29 @@ namespace dmc = crimson::dmclock; std::ostream &operator<<(std::ostream &lhs, const SchedulerClass &c) { - lhs << static_cast(c); + lhs << static_cast(c) << " ("; switch (c) { case SchedulerClass::background_best_effort: - return lhs << "background_best_effort"; + lhs << "background_best_effort"; + break; case SchedulerClass::background_recovery: - return lhs << "background_recovery"; + lhs << "background_recovery"; + break; case SchedulerClass::client: - return lhs << "client"; + lhs << "client"; + break; #ifdef WITH_CRIMSON case SchedulerClass::repop: - return lhs << "repop"; + lhs << "repop"; + break; #endif case SchedulerClass::immediate: - return lhs << "immediate"; + lhs << "immediate"; + break; default: - return lhs; + lhs << "unknown"; } + return lhs << ")"; } std::ostream& operator<<(std::ostream& out, From 89c7b217efec511be1cc5892bc181ae499bedaef Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Tue, 22 Jul 2025 13:38:16 +0530 Subject: [PATCH 198/596] osd/scheduler/mClockScheduler: Log the size of high priority queues. Signed-off-by: Sridhar Seshasayee --- src/osd/scheduler/mClockScheduler.cc | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/osd/scheduler/mClockScheduler.cc b/src/osd/scheduler/mClockScheduler.cc index bd81b6c19a1..8be0a01b6ef 100644 --- a/src/osd/scheduler/mClockScheduler.cc +++ b/src/osd/scheduler/mClockScheduler.cc @@ -99,11 +99,19 @@ void mClockScheduler::enqueue(OpSchedulerItem&& item) mclock_conf.get_mclock_counter(id); } - dout(20) << __func__ << " client_count: " << scheduler.client_count() - << " queue_sizes: [ " - << " high_priority_queue: " << high_priority.size() - << " sched: " << scheduler.request_count() << " ]" - << dendl; + dout(20) << __func__ << ": sched client_count: " << scheduler.client_count() + << " sched queue size: " << scheduler.request_count() + << dendl; + + auto fmt_prio = [this](priority_t p) -> std::string { + return (p == immediate_class_priority) ? "MAX" : std::to_string(p); + }; + + dout(20) << __func__ << " high_priority queues: " << high_priority.size(); + for (const auto& [prio, queue] : high_priority) { + *_dout << ", priority " << fmt_prio(prio) << ": " << queue.size(); + } + *_dout << dendl; dout(30) << __func__ << " mClockClients: " << scheduler << dendl; From 913e8911760eeb21d48c6d1a8dd9cd8169dda23f Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Tue, 22 Jul 2025 13:53:07 +0530 Subject: [PATCH 199/596] osd/scheduler/mClockScheduler: Fix line alignments Signed-off-by: Sridhar Seshasayee --- src/osd/scheduler/mClockScheduler.cc | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/osd/scheduler/mClockScheduler.cc b/src/osd/scheduler/mClockScheduler.cc index 8be0a01b6ef..3cef5431ce9 100644 --- a/src/osd/scheduler/mClockScheduler.cc +++ b/src/osd/scheduler/mClockScheduler.cc @@ -112,12 +112,13 @@ void mClockScheduler::enqueue(OpSchedulerItem&& item) *_dout << ", priority " << fmt_prio(prio) << ": " << queue.size(); } *_dout << dendl; - dout(30) << __func__ << " mClockClients: " - << scheduler - << dendl; - dout(30) << __func__ << " mClockQueues: { " - << display_queues() << " }" - << dendl; + + dout(30) << __func__ << " mClockClients: " + << scheduler + << dendl; + dout(30) << __func__ << " mClockQueues: { " + << display_queues() << " }" + << dendl; } void mClockScheduler::enqueue_front(OpSchedulerItem&& item) From a0464c3d82825beddb5598b15a8752e567160a05 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Tue, 22 Jul 2025 14:09:16 +0530 Subject: [PATCH 200/596] osd/scheduler: Classify EC subOp reads according to op priority for mClock The change brings MSG_OSD_EC_READ into the fold of mClock scheduler. This improves the scheduling of client and other classes of operation as they are no longer unnecessarily preempted by the 'immediate' queue. EC SubOps are now handled as follows: - EC SubOp reads generated during recovery will either go into the 'background_recovery' or 'background_best_effort' class based on the recovery priority set for the op. EC SubOp reads generated due to client will continue to be classified as 'immediate'. - EC SubOp writes generated as a result of client operations will continue to be classified as 'immediate'. - EC SubOp replies are considered high priority and therefore continue to be classed as 'immediate'. Fixes: https://tracker.ceph.com/issues/71655 Signed-off-by: Sridhar Seshasayee --- src/osd/scheduler/OpSchedulerItem.h | 45 +++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/osd/scheduler/OpSchedulerItem.h b/src/osd/scheduler/OpSchedulerItem.h index 129c3547325..21fbe1abd6b 100644 --- a/src/osd/scheduler/OpSchedulerItem.h +++ b/src/osd/scheduler/OpSchedulerItem.h @@ -237,12 +237,47 @@ public: return op; } - SchedulerClass get_scheduler_class() const final { - auto type = op->get_req()->get_type(); - if (type == CEPH_MSG_OSD_OP || - type == CEPH_MSG_OSD_BACKOFF) { + SchedulerClass get_scheduler_class() const final { + switch (op->get_req()->get_type()) { + case CEPH_MSG_OSD_OP: + case CEPH_MSG_OSD_BACKOFF: return SchedulerClass::client; - } else { + /** + * EC SubOp reads for mClock are now classed based on their priority. + * The primary reason is to prevent subOps from overwhelming the + * 'immediate' queue during OSD events like failures, removal, + * reweight and any operation that trigger recovery/backfills. A sudden + * and long enough sustained burst of subOps in the 'immediate' could + * result in slow ops since client ops are preempted due to ops in the + * higher priority 'immediate' queue. + * + * The new classification described below improves the scheduling + * of client and other classes of operation during recovery/backfill as + * they are no longer preempted by recovery EC subOps in the 'immediate' + * queue. EC SubOps are now handled as follows: + * + * - EC SubOp reads generated during recovery will either go into the + * 'background_recovery' or 'background_best_effort' class based on + * the recovery priority set for the op. EC SubOp reads generated due + * to client will continue to be classified as 'immediate'. + * + * - EC SubOp writes generated as a result of client operations will + * continue to be classified as 'immediate'. + * + * - EC SubOp replies are considered high priority and therefore + * continue to be classed as 'immediate'. + * + * Note: The 'cost' for EC subOp read operation is set according to + * the amount of data to be read/written. + */ + case MSG_OSD_EC_READ: { + auto prio = op->get_req()->get_priority(); + if (prio <= PeeringState::recovery_msg_priority_t::FORCED) { + return priority_to_scheduler_class(prio); + } + [[fallthrough]]; + } + default: return SchedulerClass::immediate; } } From e79befea328e58366c43e490c129e2a467e6db84 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Mon, 28 Jul 2025 16:39:34 +0530 Subject: [PATCH 201/596] src/messages, osd: Calculate and set cost for subOpReads for mClock scheduler Previously, sub-op reads returned a hardcoded cost of 0, bypassing mClock's background bandwidth and tag calculation mechanisms. This allowed backfill operations to proceed un-metered, occasionally causing backend resource contention and driving up client tail latencies. Cost is calculated based on whether the complete chunk/shard or a subchunk needs to be read. The possible cases are: 1. Read the complete chunk aligned length: - Cost is set to the length of the chunk aligned extent size. 2. Fragmented reads: - Consider the subchunk length and count to calculate the cost. - compute_cost evaluates the exact layout of fragmented shard bytes on disk by summing up the active subchunk allocations exactly once (`fragmented_shard_bytes += k.second * subchunk_size`). - Linear Extent Scaling: Scale the baseline footprint cleanly by multiplying it against the true count of read extents (`tl.size()`), achieving a highly efficient O(N) time complexity. This linear cost model is compatible with pools running with 'allow_ec_optimizations' set to true. Under the FastEC optimized pipeline, most operations are unified and bypass fragment slicing, meaning requests will primarily match the Case 1 chunk-aligned path. In Case 2 where applicable, the O(N) loop ensures that cost will scale proportionally according to the layout. It is important to note that the amount of data to read was set to an upper bound defined by osd_recovery_max_chunk (8 MiB) and was rounded up to the stripe width. The reason for setting a higher than actual upper bound is that there may be cases where the object doesn't have the xattrs yet to determine its size. Therefore, the amount to read was ultimatly set to ~(8 MiB / k) where k is the number of data shards. This can cause mClock to prolong the recovery times as items stay longer in the queue. To address this, the amount to read is set to the remaining length of the object to recover if the object size is known. Otherwise, the amount to read is set to the recovery chunk size as before. Therefore, in some cases, only the first recovery read could be costly if the object context is not known. The MOSDECSubOpRead class introduces the following: - cost member. This necessitates an increment to the HEAD_VERSION and appropriate handling within the encode and decode methods. - compute_cost() that is called when creating the message by ECCommonL::ReadPipeline::do_read_op(). This calls into ECSubRead::cost() that performs the actual calculations to set the cost based on the cases mentioned above. - The same sequence applies to the EC optimized path in ECCommon::ReadPipeline::do_read_op(). Fixes: https://tracker.ceph.com/issues/71655 Signed-off-by: Sridhar Seshasayee --- src/messages/MOSDECSubOpRead.h | 20 +++++++++-- src/osd/ECBackendL.cc | 11 ++++++ src/osd/ECCommon.cc | 4 +++ src/osd/ECCommonL.cc | 6 +++- src/osd/ECMsgTypes.cc | 62 ++++++++++++++++++++++++++++++++++ src/osd/ECMsgTypes.h | 8 +++++ 6 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/messages/MOSDECSubOpRead.h b/src/messages/MOSDECSubOpRead.h index 22614a910e9..9d57cef1696 100644 --- a/src/messages/MOSDECSubOpRead.h +++ b/src/messages/MOSDECSubOpRead.h @@ -21,16 +21,26 @@ class MOSDECSubOpRead : public MOSDFastDispatchOp { private: - static constexpr int HEAD_VERSION = 3; + static constexpr int HEAD_VERSION = 4; static constexpr int COMPAT_VERSION = 1; public: spg_t pgid; epoch_t map_epoch = 0, min_epoch = 0; ECSubRead op; + uint64_t cost = 0; + /** + * Calculate the cost of the SubOp read operation for mClock scheduler. + * + * @param *cct: *CephContext + * @param pair&: subchunk_count and subchunk_size + */ + void compute_cost(CephContext *cct, std::pair &subchunk_info) { + cost = op.cost(cct, subchunk_info); + } int get_cost() const override { - return 0; + return cost; } epoch_t get_map_epoch() const override { return map_epoch; @@ -58,6 +68,9 @@ public: } else { min_epoch = map_epoch; } + if (header.version >= 4) { + decode(cost, p); + } } void encode_payload(uint64_t features) override { @@ -67,6 +80,9 @@ public: encode(op, payload, features); encode(min_epoch, payload); encode_trace(payload, features); + if (header.version >= 4) { + encode(cost, payload); + } } std::string_view get_type_name() const override { return "MOSDECSubOpRead"; } diff --git a/src/osd/ECBackendL.cc b/src/osd/ECBackendL.cc index 487c7d53d59..7d98cad28f5 100644 --- a/src/osd/ECBackendL.cc +++ b/src/osd/ECBackendL.cc @@ -573,7 +573,18 @@ void ECBackendL::RecoveryBackend::continue_recovery_op( ceph_assert(!op.recovery_progress.data_complete); set want(op.missing_on_shards.begin(), op.missing_on_shards.end()); uint64_t from = op.recovery_progress.data_recovered_to; + /* When beginning recovery, the OI may not be known. As such the object + * size is not known. For the first read, attempt to read the default + * size. If this is larger than the object sizes, then the OSD will + * return truncated reads. If the object size is known, then attempt + * correctly sized reads, capped at recovery chunk size. + * (Ref: ECCommon.cc -> ECCommon::RecoveryBackend::continue_recovery_op()) + */ uint64_t amount = get_recovery_chunk_size(); + if (op.obc) { + uint64_t remaining = op.obc->obs.oi.size - from; + amount = std::min(remaining, amount); + } if (op.recovery_progress.first && op.obc) { if (auto [r, attrs, size] = ecbackend->get_attrs_n_size_from_disk(op.hoid); diff --git a/src/osd/ECCommon.cc b/src/osd/ECCommon.cc index 9982b0ffc05..cb177eb5517 100644 --- a/src/osd/ECCommon.cc +++ b/src/osd/ECCommon.cc @@ -524,6 +524,9 @@ void ECCommon::ReadPipeline::do_read_op(ReadOp &rop) { std::optional local_read_op; std::vector> m; m.reserve(messages.size()); + std::pair subchunk_info = + std::make_pair(ec_impl->get_sub_chunk_count(), + sinfo.get_chunk_size() / ec_impl->get_sub_chunk_count()); for (auto &&[pg_shard, read]: messages) { rop.in_progress.insert(pg_shard); shard_to_read_map[pg_shard].insert(rop.tid); @@ -547,6 +550,7 @@ void ECCommon::ReadPipeline::do_read_op(ReadOp &rop) { msg->trace.init("ec sub read", nullptr, &rop.trace); msg->trace.keyval("shard", pg_shard.shard.id); } + msg->compute_cost(cct, subchunk_info); m.push_back(std::make_pair(pg_shard.osd, msg)); dout(10) << __func__ << ": will send msg " << *msg << " to osd." << pg_shard.osd << dendl; diff --git a/src/osd/ECCommonL.cc b/src/osd/ECCommonL.cc index 8dc08df190a..e60cd15cbe5 100644 --- a/src/osd/ECCommonL.cc +++ b/src/osd/ECCommonL.cc @@ -468,6 +468,9 @@ void ECCommonL::ReadPipeline::do_read_op(ReadOp &op) std::vector> m; m.reserve(messages.size()); + std::pair subchunk_info = + std::make_pair(ec_impl->get_sub_chunk_count(), + sinfo.get_chunk_size() / ec_impl->get_sub_chunk_count()); for (map::iterator i = messages.begin(); i != messages.end(); ++i) { @@ -489,6 +492,7 @@ void ECCommonL::ReadPipeline::do_read_op(ReadOp &op) msg->trace.init("ec sub read", nullptr, &op.trace); msg->trace.keyval("shard", i->first.shard.id); } + msg->compute_cost(cct, subchunk_info); m.push_back(std::make_pair(i->first.osd, msg)); } if (!m.empty()) { @@ -1179,4 +1183,4 @@ ECUtilL::HashInfoRef ECCommonL::UnstableHashInfoRegistry::get_hash_info( } } -END_IGNORE_DEPRECATED \ No newline at end of file +END_IGNORE_DEPRECATED diff --git a/src/osd/ECMsgTypes.cc b/src/osd/ECMsgTypes.cc index 206378fe1f0..36480c20439 100644 --- a/src/osd/ECMsgTypes.cc +++ b/src/osd/ECMsgTypes.cc @@ -15,6 +15,8 @@ #include "ECMsgTypes.h" +#include "common/ceph_context.h" + using std::list; using std::make_pair; using std::map; @@ -266,6 +268,66 @@ void ECSubRead::decode(bufferlist::const_iterator &bl) DECODE_FINISH(bl); } +/** + * Calculate the cost of the SubOp read operation for mClock scheduler. + * Cost is calculated based on whether the complete chunk/shard + * or a subchunk needs to be read: + * Case 1. Read the complete chunk aligned length: + * - Cost is set to the length of the chunk aligned extent size. + * Case 2. Fragmented reads: + * - Cost is set by considering the subchunk length and count. + * + * Note: To retain the legacy behavior, a cost of '0' is returned as + * before for WeightedPriorityQueue scheduler. + */ +uint64_t ECSubRead::cost(CephContext *cct, std::pair& subchunk_info) +{ + uint64_t total_cost = 0; + /** + * While the cost is calculated by the primary shard with mClock + * scheduler being active, the replica shard could still be + * running on the legacy WPQ scheduler. In such a case, the + * replica shard's decoding logic would set the cost to 0, which + * is consistent with what the legacy WPQ scheduler expects. + * + * In the converse case, the primary shard running with the + * legacy WPQ scheduler sends a cost of 0. The replica shard + * running with mClock scheduler will interpret this and set + * the cost to 1 accordingly. + */ + if (cct->_conf->osd_op_queue != "mclock_scheduler") { + return total_cost; // Legacy behavior for WPQ scheduler + } + + uint64_t subchunk_size = subchunk_info.second; + + for (auto &&[hoid, tl] : to_read) { + auto it = subchunks.find(hoid); + if (it == subchunks.end()) continue; + + auto &sc = it->second; + if (sc.empty()) continue; + + // Case 1: Optimized / Complete chunk aligned read + if (sc.size() == 1 && sc.front().second == subchunk_info.first) { + for ([[maybe_unused]] auto &&[offset, len, flags] : tl) { + total_cost += len; + } + continue; + } + + // Case 2: Fragmented / Subchunk Reads + uint64_t fragmented_shard_bytes = 0; + for (auto &&k : sc) { + fragmented_shard_bytes += (uint64_t)k.second * subchunk_size; + } + total_cost += fragmented_shard_bytes * tl.size(); + } + + // Safety Boundary: mClock requires non-zero costs for tracking active ops + return std::max(total_cost, 1ULL); +} + std::ostream &operator<<( std::ostream &lhs, const ECSubRead &rhs) { diff --git a/src/osd/ECMsgTypes.h b/src/osd/ECMsgTypes.h index 72aace2335c..282ba480dc8 100644 --- a/src/osd/ECMsgTypes.h +++ b/src/osd/ECMsgTypes.h @@ -119,6 +119,14 @@ struct ECSubRead { std::map>> subchunks; std::set omap_headers_to_read; std::map> omap_read_from; + /** + * Calculate the cost of the SubOp read operation for mClock scheduler. + * + * @param *cct: *CephContext + * @param pair&: subchunk_count and subchunk_size + * @return uint64_t - Cost of EC SubOpRead (size in Bytes) + */ + uint64_t cost(CephContext *cct, std::pair& subchunk_info); void encode(ceph::buffer::list &bl, uint64_t features) const; void decode(ceph::buffer::list::const_iterator &bl); void dump(ceph::Formatter *f) const; From 5ae77683c1bb00fba249afbd5b5fd26fe95e536c Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Tue, 21 Apr 2026 18:00:50 +0530 Subject: [PATCH 202/596] mclock_common, mClockScheduler: Add perf counters for scheduler ops Add perf counters to show the status pertaining to the number of ops, dynamic queue lengths, queue latency and bytes read for the following ops handled in the high queues and in the scheduler queues: - peering - client - ec reads/writes - ec recovery reads Additional counters can be added in the future based on the requirement. Signed-off-by: Sridhar Seshasayee --- src/common/mclock_common.cc | 100 ++++++++++++++++++++++++--- src/common/mclock_common.h | 37 +++++++++- src/osd/scheduler/OpSchedulerItem.h | 32 ++++++++- src/osd/scheduler/mClockScheduler.cc | 87 ++++++++++++++++++----- src/osd/scheduler/mClockScheduler.h | 2 + 5 files changed, 230 insertions(+), 28 deletions(-) diff --git a/src/common/mclock_common.cc b/src/common/mclock_common.cc index 616681018f7..ae6eca8d9a1 100644 --- a/src/common/mclock_common.cc +++ b/src/common/mclock_common.cc @@ -248,6 +248,7 @@ void MclockConfig::init_logger() PerfCountersBuilder m(cct, "mclock-shard-queue-" + std::to_string(shard_id), l_mclock_first, l_mclock_last); + // scheduler class queue lengths m.add_u64_counter(l_mclock_immediate_queue_len, "mclock_immediate_queue_len", "high_priority op count in mclock queue"); m.add_u64_counter(l_mclock_client_queue_len, "mclock_client_queue_len", @@ -258,6 +259,38 @@ void MclockConfig::init_logger() "background_best_effort type op count in mclock queue"); m.add_u64_counter(l_mclock_all_type_queue_len, "mclock_all_type_queue_len", "all type op count in mclock queue"); + // op specific counters + // peering + m.add_time_avg(l_mclock_peering_lat, "mclock_peering_lat", + "peering op average latency in mclock high queue"); + m.add_u64_counter(l_mclock_peering_len, "mclock_peering_len", + "peering op count in mclock high queue"); + // client op + m.add_time_avg(l_mclock_client_lat, "mclock_client_lat", + "client op average latency in mclock queue"); + // ec reads + m.add_u64_counter(l_mclock_ec_r_ops, "mclock_ec_r_ops", + "ec read operations in mclock high queue"); + m.add_time_avg(l_mclock_ec_r_lat, "mclock_ec_r_lat", + "ec read latency in mclock high queue"); + m.add_u64(l_mclock_ec_r_len, "mclock_ec_r_len", + "ec reads outstanding in mclock high queue"); + // ec writes + m.add_u64_counter(l_mclock_ec_w_ops, "mclock_ec_w_ops", + "ec write operations in mclock high queue"); + m.add_time_avg(l_mclock_ec_w_lat, "mclock_ec_w_lat", + "ec write latency in mclock high queue"); + m.add_u64(l_mclock_ec_w_len, "mclock_ec_w_len", + "ec writes outstanding in mclock high queue"); + // ec recovery reads + m.add_u64_counter(l_mclock_ec_rec_r_ops, "mclock_ec_rec_r_ops", + "ec recovery read operations in mclock queue"); + m.add_u64_counter(l_mclock_ec_rec_r_outb, "mclock_ec_rec_r_outb", + "ec recovery read bytes", NULL, 0, unit_t(UNIT_BYTES)); + m.add_time_avg(l_mclock_ec_rec_r_lat, "mclock_ec_rec_r_lat", + "ec recovery read latency in mclock queue"); + m.add_u64(l_mclock_ec_rec_r_len, "mclock_ec_rec_r_len", + "ec recovery reads outstanding in mclock queue"); logger = m.create_perf_counters(); cct->get_perfcounters_collection()->add(logger); @@ -267,9 +300,23 @@ void MclockConfig::init_logger() logger->set(l_mclock_recovery_queue_len, 0); logger->set(l_mclock_best_effort_queue_len, 0); logger->set(l_mclock_all_type_queue_len, 0); + logger->set(l_mclock_peering_lat, 0); + logger->set(l_mclock_peering_len, 0); + logger->set(l_mclock_client_lat, 0); + logger->set(l_mclock_ec_r_ops, 0); + logger->set(l_mclock_ec_r_lat, 0); + logger->set(l_mclock_ec_r_len, 0); + logger->set(l_mclock_ec_w_ops, 0); + logger->set(l_mclock_ec_w_lat, 0); + logger->set(l_mclock_ec_w_len, 0); + logger->set(l_mclock_ec_rec_r_ops, 0); + logger->set(l_mclock_ec_rec_r_outb, 0); + logger->set(l_mclock_ec_rec_r_lat, 0); + logger->set(l_mclock_ec_rec_r_len, 0); } -void MclockConfig::get_mclock_counter(scheduler_id_t id) +void MclockConfig::get_mclock_counter(scheduler_id_t id, + scheduler_op_type_t op_type, uint64_t cost) { if (!logger) { return; @@ -279,47 +326,84 @@ void MclockConfig::get_mclock_counter(scheduler_id_t id) logger->inc(l_mclock_all_type_queue_len); switch (id.class_id) { - case SchedulerClass::immediate: + case SchedulerClass::immediate: { logger->inc(l_mclock_immediate_queue_len); + if (op_type == scheduler_op_type_t::ec_read_op) { + logger->inc(l_mclock_ec_r_ops); + logger->inc(l_mclock_ec_r_len); + } else if (op_type == scheduler_op_type_t::ec_write_op) { + logger->inc(l_mclock_ec_w_ops); + logger->inc(l_mclock_ec_w_len); + } else if (op_type == scheduler_op_type_t::peering_op) { + logger->inc(l_mclock_peering_len); + } break; + } case SchedulerClass::client: logger->inc(l_mclock_client_queue_len); break; case SchedulerClass::background_recovery: logger->inc(l_mclock_recovery_queue_len); break; - case SchedulerClass::background_best_effort: + case SchedulerClass::background_best_effort: { logger->inc(l_mclock_best_effort_queue_len); + if (op_type == scheduler_op_type_t::ec_rec_read_op) { + logger->inc(l_mclock_ec_rec_r_ops); + logger->inc(l_mclock_ec_rec_r_outb, cost); + logger->inc(l_mclock_ec_rec_r_len); + } break; - default: + } + default: derr << __func__ << " unknown class_id=" << id.class_id << " unknown id=" << id << dendl; break; } } -void MclockConfig::put_mclock_counter(scheduler_id_t id) +void MclockConfig::put_mclock_counter(scheduler_id_t id, + scheduler_op_type_t op_type, utime_t time_queued) { if (!logger) { return; } + auto latency = ceph_clock_now() - time_queued; + /* op leave mclock queue will -1 */ logger->dec(l_mclock_all_type_queue_len); switch (id.class_id) { - case SchedulerClass::immediate: + case SchedulerClass::immediate: { logger->dec(l_mclock_immediate_queue_len); + if (op_type == scheduler_op_type_t::ec_read_op) { + logger->dec(l_mclock_ec_r_len); + logger->tinc(l_mclock_ec_r_lat, latency); + } else if (op_type == scheduler_op_type_t::ec_write_op) { + logger->dec(l_mclock_ec_w_len); + logger->tinc(l_mclock_ec_w_lat, latency); + } else if (op_type == scheduler_op_type_t::peering_op) { + logger->dec(l_mclock_peering_len); + logger->tinc(l_mclock_peering_lat, latency); + } break; - case SchedulerClass::client: + } + case SchedulerClass::client: { logger->dec(l_mclock_client_queue_len); + logger->tinc(l_mclock_client_lat, latency); break; + } case SchedulerClass::background_recovery: logger->dec(l_mclock_recovery_queue_len); break; - case SchedulerClass::background_best_effort: + case SchedulerClass::background_best_effort: { logger->dec(l_mclock_best_effort_queue_len); + if (op_type == scheduler_op_type_t::ec_rec_read_op) { + logger->dec(l_mclock_ec_rec_r_len); + logger->tinc(l_mclock_ec_rec_r_lat, latency); + } break; + } default: derr << __func__ << " unknown class_id=" << id.class_id << " unknown id=" << id << dendl; diff --git a/src/common/mclock_common.h b/src/common/mclock_common.h index 98beb317db3..b736308159d 100644 --- a/src/common/mclock_common.h +++ b/src/common/mclock_common.h @@ -41,6 +41,15 @@ enum class scheduler_class_t : uint8_t { immediate, }; +// scheduler op types - used for perf counters +enum class scheduler_op_type_t : uint8_t { + peering_op = 0, + ec_read_op, + ec_write_op, + ec_rec_read_op, + unknown, +}; + #ifdef WITH_CRIMSON using SchedulerClass = scheduler_class_t; using MonClient = crimson::mon::Client; @@ -50,11 +59,31 @@ using SchedulerClass = op_scheduler_class; enum { l_mclock_first = 15000, + // scheduler class queue lengths l_mclock_immediate_queue_len, l_mclock_client_queue_len, l_mclock_recovery_queue_len, l_mclock_best_effort_queue_len, l_mclock_all_type_queue_len, + // op specific counters + // peering + l_mclock_peering_lat, + l_mclock_peering_len, + // client ops + l_mclock_client_lat, + // ec reads + l_mclock_ec_r_ops, + l_mclock_ec_r_lat, + l_mclock_ec_r_len, + // ec writes + l_mclock_ec_w_ops, + l_mclock_ec_w_lat, + l_mclock_ec_w_len, + // ec recovery reads + l_mclock_ec_rec_r_ops, + l_mclock_ec_rec_r_outb, + l_mclock_ec_rec_r_lat, + l_mclock_ec_rec_r_len, l_mclock_last, }; @@ -222,8 +251,12 @@ public: void set_from_config(); void init_logger(); - void get_mclock_counter(scheduler_id_t id); - void put_mclock_counter(scheduler_id_t id); + void get_mclock_counter(scheduler_id_t id, + scheduler_op_type_t op_type, + uint64_t cost); + void put_mclock_counter(scheduler_id_t id, + scheduler_op_type_t op_type, + utime_t time_queued); double get_cost_per_io() const; double get_capacity_per_shard() const; void handle_conf_change(const ConfigProxy& conf, diff --git a/src/osd/scheduler/OpSchedulerItem.h b/src/osd/scheduler/OpSchedulerItem.h index 21fbe1abd6b..e6643c627bb 100644 --- a/src/osd/scheduler/OpSchedulerItem.h +++ b/src/osd/scheduler/OpSchedulerItem.h @@ -74,6 +74,9 @@ public: virtual void run(OSD *osd, OSDShard *sdata, PGRef& pg, ThreadPool::TPHandle &handle) = 0; virtual SchedulerClass get_scheduler_class() const = 0; + virtual utime_t get_time_queued() const { + return utime_t(); + } virtual ~OpQueueable() {} friend std::ostream& operator<<(std::ostream& out, const OpQueueable& q) { @@ -166,6 +169,10 @@ public: qos_cost = scaled_cost; } + utime_t get_time_queued() const { + return qitem ? qitem->get_time_queued() : utime_t(); + } + friend std::ostream& operator<<(std::ostream& out, const OpSchedulerItem& item) { out << "OpSchedulerItem(" << item.get_ordering_token() << " " << *item.qitem; @@ -220,10 +227,12 @@ public: }; class PGOpItem : public PGOpQueueable { + utime_t time_queued; OpRequestRef op; public: - PGOpItem(spg_t pg, OpRequestRef op) : PGOpQueueable(pg), op(std::move(op)) {} + PGOpItem(spg_t pg, OpRequestRef op) + : PGOpQueueable(pg), time_queued(ceph_clock_now()), op(std::move(op)) {} std::ostream &print(std::ostream &rhs) const final { return rhs << "PGOpItem(op=" << *(op->get_req()) << ")"; @@ -282,13 +291,19 @@ public: } } + utime_t get_time_queued() const final{ + return time_queued; + } + void run(OSD *osd, OSDShard *sdata, PGRef& pg, ThreadPool::TPHandle &handle) final; }; class PGPeeringItem : public PGOpQueueable { + utime_t time_queued; PGPeeringEventRef evt; public: - PGPeeringItem(spg_t pg, PGPeeringEventRef e) : PGOpQueueable(pg), evt(e) {} + PGPeeringItem(spg_t pg, PGPeeringEventRef e) + : PGOpQueueable(pg), time_queued(ceph_clock_now()), evt(e) {} std::ostream &print(std::ostream &rhs) const final { return rhs << "PGPeeringEvent(" << evt->get_desc() << ")"; } @@ -308,6 +323,9 @@ public: SchedulerClass get_scheduler_class() const final { return SchedulerClass::immediate; } + utime_t get_time_queued() const final{ + return time_queued; + } }; class PGSnapTrim : public PGOpQueueable { @@ -537,6 +555,9 @@ public: uint64_t get_reserved_pushes() const final { return reserved_pushes; } + utime_t get_time_queued() const final { + return time_queued; + } void run( OSD *osd, OSDShard *sdata, PGRef& pg, ThreadPool::TPHandle &handle) final; SchedulerClass get_scheduler_class() const final { @@ -565,6 +586,9 @@ public: return fmt::format( "PGRecoveryContext(pgid={} c={} epoch={})", get_pgid(), (void*)c.get(), epoch); } + utime_t get_time_queued() const final { + return time_queued; + } void run( OSD *osd, OSDShard *sdata, PGRef& pg, ThreadPool::TPHandle &handle) final; SchedulerClass get_scheduler_class() const final { @@ -634,6 +658,10 @@ public: return priority_to_scheduler_class(op->get_req()->get_priority()); } + utime_t get_time_queued() const final { + return time_queued; + } + void run(OSD* osd, OSDShard* sdata, PGRef& pg, ThreadPool::TPHandle& handle) final; }; diff --git a/src/osd/scheduler/mClockScheduler.cc b/src/osd/scheduler/mClockScheduler.cc index 3cef5431ce9..a66a06fd769 100644 --- a/src/osd/scheduler/mClockScheduler.cc +++ b/src/osd/scheduler/mClockScheduler.cc @@ -73,30 +73,71 @@ void mClockScheduler::dump(ceph::Formatter &f) const f.close_section(); } +scheduler_op_type_t +mClockScheduler::get_scheduler_op_type(const OpSchedulerItem &item) +{ + if (item.is_peering()) { + return scheduler_op_type_t::peering_op; + } + + const auto op = item.maybe_get_op(); + if (!op.has_value() || !(*op) || !(*op)->get_req()) { + return scheduler_op_type_t::unknown; + } + + scheduler_op_type_t sch_op_type = scheduler_op_type_t::unknown; + const auto op_type = (*op)->get_req()->get_type(); + const auto id = get_scheduler_id(item); + switch (id.class_id) { + case SchedulerClass::immediate: { + if (op_type == MSG_OSD_EC_READ) { + sch_op_type = scheduler_op_type_t::ec_read_op; + } else if (op_type == MSG_OSD_EC_WRITE) { + sch_op_type = scheduler_op_type_t::ec_write_op; + } + break; + } + case SchedulerClass::background_best_effort: { + if (op_type == MSG_OSD_EC_READ) { + sch_op_type = scheduler_op_type_t::ec_rec_read_op; + } + break; + } + default: + break; + } + + return sch_op_type; +} + void mClockScheduler::enqueue(OpSchedulerItem&& item) { auto id = get_scheduler_id(item); unsigned priority = item.get_priority(); - + scheduler_op_type_t sch_op_type = get_scheduler_op_type(item); + auto item_cost = item.get_cost(); + // TODO: move this check into OpSchedulerItem, handle backwards compat if (SchedulerClass::immediate == id.class_id) { enqueue_high(immediate_class_priority, std::move(item)); } else if (priority >= cutoff_priority) { enqueue_high(priority, std::move(item)); } else { - auto cost = calc_scaled_cost(item.get_cost()); - item.set_qos_cost(cost); + auto qos_cost = calc_scaled_cost(item_cost); + item.set_qos_cost(qos_cost); dout(20) << __func__ << " " << id - << " item_cost: " << item.get_cost() - << " scaled_cost: " << cost + << " item_cost: " << item_cost + << " scaled_cost: " << qos_cost << dendl; + // trigger perf counter calculations first + mclock_conf.get_mclock_counter(id, sch_op_type, item_cost); + // Add item to scheduler queue scheduler.add_request( std::move(item), id, - cost); - mclock_conf.get_mclock_counter(id); + qos_cost); } dout(20) << __func__ << ": sched client_count: " << scheduler.client_count() @@ -141,17 +182,20 @@ void mClockScheduler::enqueue_high(unsigned priority, OpSchedulerItem&& item, bool front) { + scheduler_op_type_t sch_op_type = get_scheduler_op_type(item); + scheduler_id_t id = scheduler_id_t { + SchedulerClass::immediate, + client_profile_id_t() + }; + + // trigger perf counter calculations first + mclock_conf.get_mclock_counter(id, sch_op_type, item.get_cost()); + if (front) { high_priority[priority].push_back(std::move(item)); } else { high_priority[priority].push_front(std::move(item)); } - - scheduler_id_t id = scheduler_id_t { - SchedulerClass::immediate, - client_profile_id_t() - }; - mclock_conf.get_mclock_counter(id); } WorkItem mClockScheduler::dequeue() @@ -166,13 +210,15 @@ WorkItem mClockScheduler::dequeue() // maintain invariant, high priority entries are never empty high_priority.erase(iter); } - ceph_assert(std::get_if(&ret)); + auto *item_ptr = std::get_if(&ret); + ceph_assert(item_ptr); scheduler_id_t id = scheduler_id_t { SchedulerClass::immediate, client_profile_id_t() }; - mclock_conf.put_mclock_counter(id); + scheduler_op_type_t op_type = get_scheduler_op_type(*item_ptr); + mclock_conf.put_mclock_counter(id, op_type, item_ptr->get_time_queued()); return ret; } else { mclock_queue_t::PullReq result = scheduler.pull_request(); @@ -186,7 +232,16 @@ WorkItem mClockScheduler::dequeue() ceph_assert(result.is_retn()); auto &retn = result.get_retn(); - mclock_conf.put_mclock_counter(retn.client); + + // update perf counters + scheduler_op_type_t op_type = scheduler_op_type_t::unknown; + utime_t time_queued = utime_t(); + if (retn.request) { + op_type = get_scheduler_op_type(*retn.request); + time_queued = retn.request->get_time_queued(); + } + mclock_conf.put_mclock_counter(retn.client, op_type, time_queued); + return std::move(*retn.request); } } diff --git a/src/osd/scheduler/mClockScheduler.h b/src/osd/scheduler/mClockScheduler.h index 503e9058025..109532a4273 100644 --- a/src/osd/scheduler/mClockScheduler.h +++ b/src/osd/scheduler/mClockScheduler.h @@ -149,6 +149,8 @@ public: private: // Enqueue the op to the high priority queue void enqueue_high(unsigned prio, OpSchedulerItem &&item, bool front = false); + // Return the scheduler op type - used to update perf counters + scheduler_op_type_t get_scheduler_op_type(const OpSchedulerItem &item); }; } From 77421c83665fdfd3343c487793e37e003d8c1891 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Mon, 25 May 2026 17:44:54 +0530 Subject: [PATCH 203/596] mclock_common: adjust mClock profile parameters to prevent backfill starvation Adjust the 'background_best_effort' queue parameters across the three standard mClock profiles (high_client_ops, balanced, and high_recovery_ops) to ensure best effort ops are not starved. Previously, the 'background_best_effort' queue carried a default allocation of 0% (MIN) reservation and a weight of 1 under these profiles. When concurrent client traffic is dense, the zero-reservation for example completely starves backfill sub-ops (MSG_OSD_EC_READ) on pools with 'allow_ec_optimizations' set to false. This starvation forces the Primary OSD to hold internal BlueStore transactions and PG object locks for extended windows, causing severe client median (50th) latency inflation. To prevent background starvation and resolve the effects of the primary lock retention, the profile configurations are tuned as follows: The following profile changes forces low-cost sub-ops to clear out of peer queues rapidly to drop primary locks, which helps improve the client completion latency and tail latency (95th, 99th and 99.5th) percentile. 1. high_client_ops profile: - Grant 'background_best_effort' a safe 5% minimum reservation. - Scale the queue weight to 4. 2. balanced profile: - Grant 'background_best_effort' a 5% minimum reservation. - Set the queue weight to 2. 3. high_recovery_ops profile: - Grant 'background_best_effort' a 5% minimum reservation. - Set the queue weight to 2. 4. Modify the mClock config reference documentation to reflect the tuning changes to the best-effort QoS parameters across the profiles. Note on Proportional Scaling Compatibility: Configuring these changes shifts total reservations to 105% (e.g., 50% client + 50% recovery + 5% best-effort under the Balanced profile). Under heavy concurrent saturation, mClock's internal controls resolves this gracefully via proportional down-scaling, preserving the underlying device bandwidth limits for different classes of clients. For example instead of the client being allocated 50% bandwidth, a slightly lower reservation is allocated while shifting the remaining bandwidth to the best-effort queue. This minor scaling shift is virtually unnoticeable to the client application, but it prevents the internal queue deadlocks. Signed-off-by: Sridhar Seshasayee --- doc/rados/configuration/mclock-config-ref.rst | 6 ++--- src/common/mclock_common.h | 24 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/doc/rados/configuration/mclock-config-ref.rst b/doc/rados/configuration/mclock-config-ref.rst index 95d6e52c91e..316e1faa5e1 100644 --- a/doc/rados/configuration/mclock-config-ref.rst +++ b/doc/rados/configuration/mclock-config-ref.rst @@ -128,7 +128,7 @@ built-in profiles may be enabled by following the steps mentioned in next sectio +------------------------+-------------+--------+-------+ | background recovery | 50% | 1 | MAX | +------------------------+-------------+--------+-------+ -| background best-effort | MIN | 1 | 90% | +| background best-effort | 5% | 2 | 90% | +------------------------+-------------+--------+-------+ high_client_ops @@ -147,7 +147,7 @@ the resource control parameters set by the profile: +------------------------+-------------+--------+-------+ | background recovery | 40% | 1 | MAX | +------------------------+-------------+--------+-------+ -| background best-effort | MIN | 1 | 70% | +| background best-effort | 5% | 4 | 70% | +------------------------+-------------+--------+-------+ high_recovery_ops @@ -165,7 +165,7 @@ parameters set by the profile: +------------------------+-------------+--------+-------+ | background recovery | 70% | 2 | MAX | +------------------------+-------------+--------+-------+ -| background best-effort | MIN | 1 | MAX | +| background best-effort | 5% | 2 | MAX | +------------------------+-------------+--------+-------+ .. note:: Across the built-in profiles, internal background best-effort clients diff --git a/src/common/mclock_common.h b/src/common/mclock_common.h index b736308159d..498da12ca5a 100644 --- a/src/common/mclock_common.h +++ b/src/common/mclock_common.h @@ -128,12 +128,12 @@ struct profile_t { * Background Recovery Allocation: * reservation: 40% | weight: 1 | limit: 0 (max) | * Background Best Effort Allocation: - * reservation: 0 (min) | weight: 1 | limit: 70% | + * reservation: 5% | weight: 4 | limit: 70% | */ constexpr profile_t HIGH_CLIENT_OPS{ - { .6, 2, 0 }, - { .4, 1, 0 }, - { 0, 1, .7 } + { .6, 2, 0 }, + { .4, 1, 0 }, + { .05, 4, .7 } }; /** @@ -144,12 +144,12 @@ constexpr profile_t HIGH_CLIENT_OPS{ * Background Recovery Allocation: * reservation: 70% | weight: 2 | limit: 0 (max) | * Background Best Effort Allocation: - * reservation: 0 (min) | weight: 1 | limit: 0 (max) | + * reservation: 5% | weight: 2 | limit: 0 (max) | */ constexpr profile_t HIGH_RECOVERY_OPS{ - { .3, 1, 0 }, - { .7, 2, 0 }, - { 0, 1, 0 } + { .3, 1, 0 }, + { .7, 2, 0 }, + { .05, 2, 0 } }; /** @@ -160,12 +160,12 @@ constexpr profile_t HIGH_RECOVERY_OPS{ * Background Recovery Allocation: * reservation: 50% | weight: 1 | limit: 0 (max) | * Background Best Effort Allocation: - * reservation: 0 (min) | weight: 1 | limit: 90% | + * reservation: 5% | weight: 2 | limit: 90% | */ constexpr profile_t BALANCED{ - { .5, 1, 0 }, - { .5, 1, 0 }, - { 0, 1, .9 } + { .5, 1, 0 }, + { .5, 1, 0 }, + { .05, 2, .9 } }; struct client_profile_id_t { From 2cf1e713cec59c989602c001e710ba499005ab04 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Thu, 4 Jun 2026 12:28:35 +0530 Subject: [PATCH 204/596] doc/rados/configuration: Remove wpq recommendation warning for EC clusters Remove the warning that recommends using wpq scheduler as a fallback for EC clusters. This issue is addressed by considering EC recovery reads as background, assigning an accurate cost for those reads and tuning the QoS parameters associated with best-effort class of operations. Signed-off-by: Sridhar Seshasayee --- doc/rados/configuration/mclock-config-ref.rst | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/doc/rados/configuration/mclock-config-ref.rst b/doc/rados/configuration/mclock-config-ref.rst index 316e1faa5e1..fc5201b32fc 100644 --- a/doc/rados/configuration/mclock-config-ref.rst +++ b/doc/rados/configuration/mclock-config-ref.rst @@ -4,22 +4,6 @@ .. index:: mclock; configuration -.. warning:: On large clusters with erasure-coded pools, operators may - observe slow ops during recovery or backfill (for example, when an - OSD is drained out). Under mClock, EC sub-operation reads issued - during recovery are currently routed through the ``immediate`` - high-priority queue and bypass mClock throttling. When many OSDs - read concurrently from a single source OSD, this can saturate that - OSD's high-priority queue and starve client and background work. - As an interim measure, such deployments are advised to switch to - the ``WeightedPriorityQueue`` (``wpq``) scheduler. The change can - be applied cluster-wide and takes effect after each OSD is - restarted: - - .. prompt:: bash # - - ceph config set osd osd_op_queue wpq - QoS support in Ceph is implemented using a queuing scheduler based on `the dmClock algorithm`_. See :ref:`dmclock-qos` section for more details. From 63b91836b00bae9fb2ba19ee837edccb7f2840bb Mon Sep 17 00:00:00 2001 From: "Ashwin M. Joshi" Date: Tue, 10 Feb 2026 11:59:49 +0530 Subject: [PATCH 205/596] mgr/cephadm: Control cephadm.log messages based on a new mgr logging level flag Introduces a new 'cephadm_binary_logging_level' config option to control the verbosity of cephadm logging to persistent destinations (cephadm.log, syslog). - Adds --logging-level CLI flag (info, debug, error, warning) - Adds mgr/cephadm/cephadm_binary_logging_level config option - Applies logging level to file and syslog handlers - Console handlers maintain their defaults for terminal UX Fixes: https://tracker.ceph.com/issues/74872 Signed-off-by: Ashwin M. Joshi --- src/cephadm/cephadm.py | 5 + src/cephadm/cephadmlib/context.py | 1 + src/cephadm/cephadmlib/logging.py | 13 ++- src/pybind/mgr/cephadm/module.py | 8 ++ src/pybind/mgr/cephadm/serve.py | 3 + src/pybind/mgr/cephadm/tests/test_cephadm.py | 109 +++++++++++++++++++ 6 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 45cb599e648..4bf0f2b1015 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -4977,6 +4977,11 @@ def _get_parser(): action='store_true', default=False, help='Do not run containers with --cgroups=split (currently only relevant when using podman)') + parser.add_argument( + '--logging-level', + choices=['info', 'debug', 'error', 'warning'], + default='debug', + help='Tunable log level for cephadm binary: info, debug, error, warning (default: debug)') subparsers = parser.add_subparsers(help='sub-command') diff --git a/src/cephadm/cephadmlib/context.py b/src/cephadm/cephadmlib/context.py index 3411c199ebb..502d2c239c7 100644 --- a/src/cephadm/cephadmlib/context.py +++ b/src/cephadm/cephadmlib/context.py @@ -31,6 +31,7 @@ class BaseConfig: self.memory_request: Optional[int] = None self.memory_limit: Optional[int] = None self.log_to_journald: Optional[bool] = None + self.logging_level: str = 'debug' self.container_init: bool = CONTAINER_INIT # FIXME(refactor) : should be Optional[ContainerEngine] diff --git a/src/cephadm/cephadmlib/logging.py b/src/cephadm/cephadmlib/logging.py index f722a33e78d..b343e0d79dc 100644 --- a/src/cephadm/cephadmlib/logging.py +++ b/src/cephadm/cephadmlib/logging.py @@ -173,7 +173,9 @@ def _copy(obj: Any) -> Any: def _complete_logging_config( - interactive: bool, destinations: Optional[List[str]] + interactive: bool, + destinations: Optional[List[str]], + logging_level: str = 'debug', ) -> Dict[str, Any]: """Return a logging configuration dict, based on the runtime parameters cephadm was invoked with. @@ -183,6 +185,12 @@ def _complete_logging_config( if interactive: lc = _copy(_interactive_logging_config) + # Apply logging level to persistent destinations only (cephadm.log, syslog). + # Console handlers keep their template defaults for terminal UX. + level_upper = logging_level.upper() + lc['handlers']['log_file']['level'] = level_upper + lc['handlers']['syslog']['level'] = level_upper + handlers = lc['loggers']['']['handlers'] if not destinations: handlers.append(LogDestination.file.value) @@ -206,9 +214,11 @@ def cephadm_init_logging( if not os.path.exists(LOG_DIR): os.makedirs(LOG_DIR) + logging_level = getattr(ctx, 'logging_level', 'debug').lower() lc = _complete_logging_config( any(op in args for op in _INTERACTIVE_CMDS), getattr(ctx, 'log_dest', None), + logging_level=logging_level, ) logging.config.dictConfig(lc) @@ -228,6 +238,7 @@ def cephadm_init_logging( # option is set if ctx.verbose and handler.name in _VERBOSE_HANDLERS: handler.setLevel(QUIET_LOG_LEVEL) + logger.debug('%s\ncephadm %s' % ('-' * 80, args)) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index ef4699838d1..555b7065761 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -509,6 +509,13 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): desc="Destination for cephadm command persistent logging", enum_allowed=['file', 'syslog', 'file,syslog'], ), + Option( + 'cephadm_binary_logging_level', + type='str', + default='debug', + desc='Logging verbosity for the cephadm binary when invoked by the mgr (e.g. check-host, gather-facts).', + enum_allowed=['info', 'debug', 'error', 'warning'] + ), Option( 'oob_default_addr', type='str', @@ -622,6 +629,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.certificate_automated_rotation_enabled = False self.certificate_check_debug_mode = False self.certificate_check_period = 0 + self.cephadm_binary_logging_level = 'debug' self.notify(NotifyType.mon_map, None) self.config_notify() diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 1f1b5c2fd6f..e4f57d8a7ee 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1738,6 +1738,9 @@ class CephadmServe: if image: final_args.extend(['--image', image]) + cephadm_log_level = self.mgr.cephadm_binary_logging_level or 'debug' + final_args.extend(['--logging-level', cephadm_log_level]) + if not self.mgr.container_init: final_args += ['--no-container-init'] diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index e462d17e761..269a1664e68 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -3117,3 +3117,112 @@ Traceback (most recent call last): assert wait(cephadm_module, c) == ['Scheduled osd.foo update...'] cephadm_module.set_osd_spec('osd.foo', ['1']) + + +class TestCephadmBinaryLoggingLevel: + """Test that host-status / cephadm binary logs are suppressed based on + mgr/cephadm/cephadm_binary_logging_level. + """ + @pytest.mark.parametrize("logging_level", ['info', 'debug', 'error', 'warning']) + @mock.patch("cephadm.ssh.SSHManager._remote_connection") + @mock.patch("cephadm.ssh.SSHManager._execute_command") + @mock.patch("cephadm.ssh.SSHManager._check_execute_command") + def test_check_host_invokes_cephadm_with_logging_level( + self, check_execute_command, execute_command, remote_connection, cephadm_module, logging_level + ): + """Cephadm binary must be invoked with --logging-level matching + mgr/cephadm/cephadm_binary_logging_level (info, debug, error, warning). + Check that mgr builds the cephadm command with appropriate --logging-level flag + Use check-host as a sample command although all cephadm commands receive the flag. + """ + remote_connection.side_effect = async_side_effect(mock.Mock()) + check_execute_command.side_effect = async_side_effect('/usr/bin/python3') + captured_commands = [] + + async def capture_execute(host, cmd, *args, **kwargs): + if hasattr(cmd, 'args'): + if 'check-host' in cmd.args: + captured_commands.append(cmd) + # Return valid JSON for commands that use _run_cephadm_json + if 'ls' in cmd.args: + return ('[]', '', 0) + if 'gather-facts' in cmd.args: + return ('{}', '', 0) + if 'list-networks' in cmd.args: + return ('[]', '', 0) + return ('', '', 0) + + execute_command.side_effect = capture_execute + + cephadm_module.cephadm_binary_logging_level = logging_level + with with_host(cephadm_module, 'test'): + pass + + check_host_cmds = [c for c in captured_commands if 'check-host' in c.args] + assert len(check_host_cmds) >= 1, ( + f'expected at least one check-host invocation for level {logging_level!r}' + ) + cmd = check_host_cmds[0] + assert '--logging-level' in cmd.args, ( + f'cephadm should be called with --logging-level when level is {logging_level!r}' + ) + idx = cmd.args.index('--logging-level') + assert cmd.args[idx + 1] == logging_level, ( + f'cephadm should be called with --logging-level {logging_level!r}' + ) + + +class TestCephadmLogDestination: + """Test that cephadm is invoked with --log-dest matching + mgr/cephadm/cephadm_log_destination. + """ + @pytest.mark.parametrize( + "log_destination,expected_log_dests", + [ + ('file', ['file']), + ('syslog', ['syslog']), + ('file,syslog', ['file', 'syslog']), + ], + ) + @mock.patch("cephadm.ssh.SSHManager._remote_connection") + @mock.patch("cephadm.ssh.SSHManager._execute_command") + @mock.patch("cephadm.ssh.SSHManager._check_execute_command") + def test_check_host_invokes_cephadm_with_log_dest( + self, check_execute_command, execute_command, remote_connection, + cephadm_module, log_destination, expected_log_dests, + ): + """check-host must be invoked with --log-dest matching + mgr/cephadm/cephadm_log_destination (file, syslog, or both). + """ + remote_connection.side_effect = async_side_effect(mock.Mock()) + check_execute_command.side_effect = async_side_effect('/usr/bin/python3') + captured_commands = [] + + async def capture_execute(host, cmd, *args, **kwargs): + if hasattr(cmd, 'args'): + if 'check-host' in cmd.args: + captured_commands.append(cmd) + if 'ls' in cmd.args: + return ('[]', '', 0) + if 'gather-facts' in cmd.args: + return ('{}', '', 0) + if 'list-networks' in cmd.args: + return ('[]', '', 0) + return ('', '', 0) + + execute_command.side_effect = capture_execute + + cephadm_module.cephadm_log_destination = log_destination + with with_host(cephadm_module, 'test'): + pass + + check_host_cmds = [c for c in captured_commands if 'check-host' in c.args] + assert len(check_host_cmds) >= 1, ( + f'expected at least one check-host invocation for dest {log_destination!r}' + ) + cmd = check_host_cmds[0] + for dest in expected_log_dests: + assert f'--log-dest={dest}' in cmd.args, ( + f'cephadm should be called with --log-dest={dest!r} ' + f'when cephadm_log_destination is {log_destination!r}' + ) From e81c276963672626e6727cbf72392e25ac74c89c Mon Sep 17 00:00:00 2001 From: "Ashwin M. Joshi" Date: Fri, 12 Jun 2026 14:08:18 +0530 Subject: [PATCH 206/596] doc/cephadm: Document cephadm_binary_logging_level option Add documentation for the new cephadm binary logging level configuration Fixes: https://tracker.ceph.com/issues/74872 Signed-off-by: Ashwin M. Joshi --- doc/cephadm/operations.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doc/cephadm/operations.rst b/doc/cephadm/operations.rst index dc4a3c625ed..cc26465ecb4 100644 --- a/doc/cephadm/operations.rst +++ b/doc/cephadm/operations.rst @@ -393,6 +393,34 @@ For example: ``--log-dest`` options described in the bootstrap section above. +Setting Cephadm Log Verbosity +----------------------------- + +The verbosity of cephadm's persistent logging (``/var/log/ceph/cephadm.log`` +and syslog, when enabled) can be controlled separately from its terminal +output. Valid levels are ``info``, ``debug``, ``error``, and ``warning``. +The default is ``debug``. + +When the cephadm orchestration module runs ``cephadm`` on cluster hosts, it +uses the :confval:`mgr/cephadm/cephadm_binary_logging_level` configuration +value: + +.. prompt:: bash # + + ceph config set mgr mgr/cephadm/cephadm_binary_logging_level info + +For manual invocations, pass ``--logging-level`` on the command line: + +.. prompt:: bash # + + cephadm --logging-level info check-host + +.. note:: The logging level applies only to persistent log destinations + (the log file and syslog). Console output during bootstrap and other + interactive use is unchanged. When you run ``cephadm`` directly, the + mgr configuration does not apply; use ``--logging-level`` as shown above. + + Data Location ============= From 745f16a58ac8a9a160f349f7b932bc964f1ce75d Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Fri, 5 Jun 2026 10:14:28 +0200 Subject: [PATCH 207/596] qa: extend the ignore-list for the mgmt-gateway test suite Let's add CEPHADM_AGENT_DOWN and CEPHADM_STRAY_DAEMON errors Fixes: https://tracker.ceph.com/issues/77131 Signed-off-by: Redouane Kachach --- qa/suites/orch/cephadm/workunits/task/test_mgmt_gateway.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/qa/suites/orch/cephadm/workunits/task/test_mgmt_gateway.yaml b/qa/suites/orch/cephadm/workunits/task/test_mgmt_gateway.yaml index 7d37f65bf90..fae26afc9a9 100644 --- a/qa/suites/orch/cephadm/workunits/task/test_mgmt_gateway.yaml +++ b/qa/suites/orch/cephadm/workunits/task/test_mgmt_gateway.yaml @@ -2,6 +2,7 @@ overrides: ceph: log-ignorelist: - CEPHADM_FAILED_DAEMON + - CEPHADM_AGENT_DOWN log-only-match: - CEPHADM_ roles: From b4499d4f9afdb85ee87c36fe6b6e5a64677dade9 Mon Sep 17 00:00:00 2001 From: Kobi Ginon Date: Fri, 12 Jun 2026 19:01:44 +0300 Subject: [PATCH 208/596] mgr/test_orchestrator: accept force_delete_data in remove_daemons PR #68428 plumbed force_delete_data through orchestrator daemon/service removal; update the test_orchestrator stub so orchestrator_cli QA (test_mds_rm, etc.) does not fail with an unexpected keyword argument. Fixes: https://tracker.ceph.com/issues/77374 Signed-off-by: Kobi Ginon --- src/pybind/mgr/test_orchestrator/module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/test_orchestrator/module.py b/src/pybind/mgr/test_orchestrator/module.py index e63efbdbdd8..9ccec1338a2 100644 --- a/src/pybind/mgr/test_orchestrator/module.py +++ b/src/pybind/mgr/test_orchestrator/module.py @@ -225,12 +225,12 @@ class TestOrchestrator(MgrModule, orchestrator.Orchestrator): return [self._create_osds(dg) for dg in specs] @handle_orch_error - def remove_daemons(self, names): + def remove_daemons(self, names, force_delete_data=False): assert isinstance(names, list) return 'done' @handle_orch_error - def remove_service(self, service_name, force = False): + def remove_service(self, service_name, force=False, force_delete_data=False): assert isinstance(service_name, str) return 'done' From d7fedf1ecead7ad74579c2236f9fa175bc0c0ef4 Mon Sep 17 00:00:00 2001 From: Adam Kupczyk Date: Tue, 9 Jun 2026 20:16:03 +0200 Subject: [PATCH 209/596] doc/rados/bluestore: Fast onode recovery Add new page of documentation about new feature: multithread onode recovery. Signed-off-by: Adam Kupczyk --- doc/rados/bluestore/fast-onode-scan.rst | 168 ++++++++++++++++++++++++ doc/rados/configuration/index.rst | 1 + 2 files changed, 169 insertions(+) create mode 100644 doc/rados/bluestore/fast-onode-scan.rst diff --git a/doc/rados/bluestore/fast-onode-scan.rst b/doc/rados/bluestore/fast-onode-scan.rst new file mode 100644 index 00000000000..f82f21aab9d --- /dev/null +++ b/doc/rados/bluestore/fast-onode-scan.rst @@ -0,0 +1,168 @@ +============================ + Multithread Onode Scanning +============================ + +.. index:: bluestore; rocksdb; allocator + +Since Pacific release BlueStore has the option to select 5-10% latency reduction +at a cost of significantly longer ``OSD`` recovery after crash. + +.. confval:: bluestore_allocation_from_file + +During crash recovery BlueStore must reconstruct allocator state by scanning all stored objects. +On systems with millions of objects this process can take several minutes. +This page presents new multithreaded onode recovery that significantly reduces recovery time. + +Allocator +========= + +In BlueStore ``Allocator`` is responsible for tracking unused allocation units on the disk. +Up to 3 allocators can be in use concurrently, one for Main, one for DB, and one for WAL. +Only Main device can contain RADOS object, and only Main's allocator is relevant here. +When BlueStore is running, allocator state is fully loaded to memory. That way +allocator can quickly respond when there is a need to allocate disk space for object, +or when object no longer exists on disk. + +RocksDB persisted allocator +--------------------------- + +Original design envisioned that allocator state updates are stored in RocksDB's ``b`` column. +When RADOS object change allocates or releases allocation unit the relevant state update information +is kept together with object-modifying RocksDB atomic transaction. The information transfer is unidirectional, +BlueStore is updating allocator state in RocksDB, but never retrieving it. +The exception is OSD bootup when BlueStore retrieves allocator state from RocksDB ``b`` column. + +File persisted allocator +------------------------ + +It was tested that keeping RocksDB busy updating ``b`` column with allocation data +has observable cost on write latency and cpu burden on compaction. +To get that extra performance back the new default mode is to update in-memory allocator state only. +Instead, on shutdown state is saved to BlueFS file named ``ALLOCATOR_NCB_DIR/ALLOCATOR_NCB_FILE``. + +Crash recovery +-------------- + +If BlueStore was not shutdown orderly there is no file to read allocator state from. +Since it is the objects that define what is in use, the recovery procedure iterates over all objects and extracts used allocations. + +Multi-thread recovery +===================== + +Iterating over all onodes in the RocksDB can take several minutes. +To help with that a multi-threaded recovery procedure is created. +It is significantly different procedure than the original one. +There is just one configurable that selects and controls recovery. + +.. confval:: bluestore_allocation_recovery_threads + + +Performance +----------- + +Example recovery timings. +OSD with NVME SSD, hosting 7.5M RBD objects, 4.6TiB used. + ++------------------------+------------+------------+ +| recovery mechanism | threads | time(s) | ++========================+============+============+ +| original | 1 | 76.0 | ++------------------------+------------+------------+ +| multithread | 1 | 55.8 | ++------------------------+------------+------------+ +| multithread | 4 | 18.1 | ++------------------------+------------+------------+ +| multithread | 8 | 10.8 | ++------------------------+------------+------------+ +| multithread | 12 | 8.0 | ++------------------------+------------+------------+ +| multithread | 16 | 6.8 | ++------------------------+------------+------------+ +| multithread | 32 | 5.3 | ++------------------------+------------+------------+ + +Testing +------- + +If long recovery time has been a deciding factor for staying with allocations in RocksDB, +there is a procedure for measuring directly recovery time. + +.. prompt:: bash # + + ceph-bluestore-tool --path recovery-compare + +.. code-block:: + + recovery-compare bluestore compare new and legacy onode recovery + Legacy recovery start + Legacy recovery result=0 took 76.033992 seconds + Legacy recovery stats= + ========================================================== + onode_count = 7500217 + shard_count = 9754950 + shared_blob_count = 0 + compressed_blob_count = 0 + spanning_blob_count = 264519 + skipped_illegal_extent = 100815206 + extent_count = 188685236 + insert_count = 0 + store store_statfs(0x0/0x0/0x0, data 0x0/0x0, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 1 store_statfs(0x0/0x0/0x0, data 0x90220/0x94000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 2 store_statfs(0x0/0x0/0x0, data 0x4348a70e000/0x4c7ee684000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 18446744073709551615 store_statfs(0x0/0x0/0x0, data 0x26a3f/0x150000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + ========================================================== + + bluestore_allocation_recovery_threads = 0 + No multithread recovery to compare. + recovery-compare success + +.. prompt:: bash # + + ceph-bluestore-tool --path --bluestore_allocation_recovery_threads=12 recovery-compare + +.. code-block:: + + recovery-compare bluestore compare new and legacy onode recovery + New recovery start + New recovery result=0 took 8.033309 seconds + New recovery stats= + ========================================================== + onode_count = 7500217 + shard_count = 9754950 + shared_blob_count = 0 + compressed_blob_count = 0 + spanning_blob_count = 264519 + skipped_illegal_extent = 0 + extent_count = 188685236 + insert_count = 0 + store store_statfs(0x0/0x0/0x0, data 0x0/0x0, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 1 store_statfs(0x0/0x0/0x0, data 0x90220/0x94000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 2 store_statfs(0x0/0x0/0x0, data 0x4348a70e000/0x4c7ee684000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 18446744073709551615 store_statfs(0x0/0x0/0x0, data 0x26a3f/0x150000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + ========================================================== + + Legacy recovery start + Legacy recovery result=0 took 62.210723 seconds + Legacy recovery stats= + ========================================================== + onode_count = 7500217 + shard_count = 9754950 + shared_blob_count = 0 + compressed_blob_count = 0 + spanning_blob_count = 264519 + skipped_illegal_extent = 100815206 + extent_count = 188685236 + insert_count = 0 + store store_statfs(0x0/0x0/0x0, data 0x0/0x0, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 1 store_statfs(0x0/0x0/0x0, data 0x90220/0x94000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 2 store_statfs(0x0/0x0/0x0, data 0x4348a70e000/0x4c7ee684000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + pool 18446744073709551615 store_statfs(0x0/0x0/0x0, data 0x26a3f/0x150000, compress 0x0/0x0/0x0, omap 0x0, meta 0x0) + ========================================================== + + FSstats the same. + Allocators the same. + recovery-compare success + +One can see above discrepancy between legacy recovery times: 76.0s vs 62.2s. +This is an effect of RocksDB having the data already cached by new recovery when legacy recovery is the second one. + diff --git a/doc/rados/configuration/index.rst b/doc/rados/configuration/index.rst index 01b83f95472..0367b449416 100644 --- a/doc/rados/configuration/index.rst +++ b/doc/rados/configuration/index.rst @@ -22,6 +22,7 @@ For general object store configuration, refer to the following: Storage devices BlueStore RocksDB cache <../bluestore/rocksdb-config> BlueFS Spillover Cleaner <../bluestore/bluefs-spillover-cleaner> + Fast Crash Recovery for file-stored allocations <../bluestore/fast-onode-scan> ceph-conf From ad02618007a60dcf92eb93c50b5c14cc6cf72c44 Mon Sep 17 00:00:00 2001 From: Adam Kupczyk Date: Wed, 10 Jun 2026 08:50:21 +0200 Subject: [PATCH 210/596] doc/man/8/ceph-bluestore-tool: Add doc for recovery-compare command Signed-off-by: Adam Kupczyk --- doc/man/8/ceph-bluestore-tool.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/man/8/ceph-bluestore-tool.rst b/doc/man/8/ceph-bluestore-tool.rst index a958b298d49..276b4747e3a 100644 --- a/doc/man/8/ceph-bluestore-tool.rst +++ b/doc/man/8/ceph-bluestore-tool.rst @@ -20,6 +20,7 @@ Synopsis | **ceph-bluestore-tool** qfsck --path *osd path* | **ceph-bluestore-tool** allocmap --path *osd path* | **ceph-bluestore-tool** restore_cfb --path *osd path* +| **ceph-bluestore-tool** recovery-compare --path *osd path* | **ceph-bluestore-tool** show-label --dev *device* ... | **ceph-bluestore-tool** show-label-at --dev *device* --offset *lba* ... | **ceph-bluestore-tool** prime-osd-dir --dev *device* --path *osd path* @@ -72,6 +73,9 @@ Commands Reverses changes done by the new NCB code (either through ceph restart or when running allocmap command) and restores RocksDB B Column-Family (allocator-map). +:command:`recovery-compare` + + Runs legacy onode recovery and multithread onode recovery. Prints timings and compares results. :command:`bluefs-export` From 23d7ec54d0b687b5c3e5cf3747fb0188393aa91b Mon Sep 17 00:00:00 2001 From: Adam Kupczyk Date: Fri, 12 Jun 2026 19:10:23 +0200 Subject: [PATCH 211/596] doc/rados/bluestore: Fix flags for bluestore_allocation_from_file Set bluestore_allocation_from_file flags to 'startup'. Without it, documentation claims the flag to be 'runtime updateable'. Signed-off-by: Adam Kupczyk --- src/common/options/global.yaml.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/common/options/global.yaml.in b/src/common/options/global.yaml.in index 26391bfd64e..093e2907690 100644 --- a/src/common/options/global.yaml.in +++ b/src/common/options/global.yaml.in @@ -5460,6 +5460,8 @@ options: desc: Remove allocation info from RocksDB and store the info in a new allocation file default: true with_legacy: true + flags: + - startup - name: bluestore_allocation_recovery_threads type: uint level: basic From 1b6cbbbe23c81de20e4828b50fd94da48ab64781 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 16 Feb 2026 16:29:31 +0530 Subject: [PATCH 212/596] tools/cephfs_mirror: Add inprogress bytes and files metric Add following mirroring progress metrics to current_syncing_snap as below bytes: sync_bytes - bytes synced till now total_bytes - total bytes to be synced sync_percent - Percentage of bytes synced till now files: total_files - Total files to be synced sync_files - files synced till now sync_percent - Percentage of files synced till now sync_files and sync_bytes are also stored in last_synced_snap section after the snapshot is synced. The bytes is formatted as below. Sample output: -------- { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 3, "name": "d0_snap1", "bytes": { "sync_bytes": "120.20 MiB", "total_bytes": "149.94 MiB", "sync_percent": "80.16%" }, "files": { "sync_files": 4032, "total_files": 5000, "sync_percent": "80.64%" } }, "last_synced_snap": { "id": 2, "name": "d0_snap0", "sync_duration": 45, "sync_time_stamp": "5642.805770s", "sync_bytes": "300.85 MiB", "sync_files": 10000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 55 ++++++++++++++++++++++++- src/tools/cephfs_mirror/PeerReplayer.h | 30 +++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index d584b419790..ebadde544f8 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -910,6 +910,7 @@ int PeerReplayer::remote_file_op(std::shared_ptr& syncm, const st } } + inc_sync_files(dir_root); return 0; } @@ -1330,6 +1331,7 @@ PeerReplayer::SyncMechanism::~SyncMechanism() { void PeerReplayer::SyncMechanism::push_dataq_entry(SyncEntry e) { dout(10) << ": snapshot data replayer dataq pushed" << " syncm=" << this << " epath=" << e.epath << dendl; + m_peer_replayer.inc_total_bytes_files(std::string(m_dir_root), e.stx.stx_size); std::unique_lock lock(sdq_lock); m_sync_dataq.push(std::move(e)); sdq_cv.notify_all(); @@ -2522,6 +2524,32 @@ void PeerReplayer::run_datasync(SnapshotDataSyncThread *data_replayer) { } // outer while } +std::string PeerReplayer::format_bytes(double bytes) { + static constexpr double KiB = 1024.0; + static constexpr double MiB = KiB * 1024.0; + static constexpr double GiB = MiB * 1024.0; + static constexpr double TiB = GiB * 1024.0; + static constexpr double PiB = TiB * 1024.0; + + std::ostringstream out; + out << std::fixed << std::setprecision(2); + + if (bytes >= PiB) { + out << (bytes / PiB) << " PiB"; + } else if (bytes >= TiB) { + out << (bytes / TiB) << " TiB"; + } else if (bytes >= GiB) { + out << (bytes / GiB) << " GiB"; + } else if (bytes >= MiB) { + out << (bytes / MiB) << " MiB"; + } else if (bytes >= KiB) { + out << (bytes / KiB) << " KiB"; + } else { + out << bytes << " B"; + } + return out.str(); +}; + void PeerReplayer::peer_status(Formatter *f) { std::scoped_lock locker(m_lock); f->open_object_section("stats"); @@ -2539,7 +2567,27 @@ void PeerReplayer::peer_status(Formatter *f) { f->open_object_section("current_syncing_snap"); f->dump_unsigned("id", (*sync_stat.current_syncing_snap).first); f->dump_string("name", (*sync_stat.current_syncing_snap).second); - f->close_section(); + f->open_object_section("bytes"); + f->dump_string("sync_bytes", format_bytes(sync_stat.sync_bytes)); + f->dump_string("total_bytes", format_bytes(sync_stat.total_bytes)); + if (sync_stat.total_bytes > 0) { + double sync_pct = (static_cast(sync_stat.sync_bytes) * 100.0) / sync_stat.total_bytes; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_pct << "%"; + f->dump_string("sync_percent", os.str()); + } + f->close_section(); //bytes + f->open_object_section("files"); + f->dump_unsigned("sync_files", sync_stat.sync_files); + f->dump_unsigned("total_files", sync_stat.total_files); + if (sync_stat.total_files > 0) { + double sync_file_pct = (static_cast(sync_stat.sync_files) * 100.0) / sync_stat.total_files; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_file_pct << "%"; + f->dump_string("sync_percent", os.str()); + } + f->close_section(); //files + f->close_section(); //current_syncing_snap } if (sync_stat.last_synced_snap) { f->open_object_section("last_synced_snap"); @@ -2550,7 +2598,10 @@ void PeerReplayer::peer_status(Formatter *f) { f->dump_stream("sync_time_stamp") << sync_stat.last_synced; } if (sync_stat.last_sync_bytes) { - f->dump_unsigned("sync_bytes", *sync_stat.last_sync_bytes); + f->dump_string("sync_bytes", format_bytes(*sync_stat.last_sync_bytes)); + } + if (sync_stat.last_sync_files) { + f->dump_unsigned("sync_files", *sync_stat.last_sync_files); } f->close_section(); } diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 3609c992601..bb5e467da53 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -385,7 +385,11 @@ private: monotime last_synced = clock::zero(); boost::optional last_sync_duration; boost::optional last_sync_bytes; //last sync bytes for display in status + boost::optional last_sync_files; //last num of sync files for display in status uint64_t sync_bytes = 0; //sync bytes counter, independently for each directory sync. + uint64_t total_bytes = 0; //total bytes counter, independently for each directory sync. + uint64_t sync_files = 0; //sync files counter, independently for each directory sync. + uint64_t total_files = 0; //total files counter, independently for each directory sync. }; void _inc_failed_count(const std::string &dir_root) { @@ -415,6 +419,13 @@ private: sync_stat.last_failed_reason = boost::none; } + void _reset_sync_stat(const std::string &dir_root) { + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.sync_bytes = 0; + sync_stat.total_bytes = 0; + sync_stat.sync_files = 0; + sync_stat.total_files = 0; + } void _set_last_synced_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { auto &sync_stat = m_snap_sync_stats.at(dir_root); @@ -425,14 +436,13 @@ private: const std::string &snap_name) { std::scoped_lock locker(m_lock); _set_last_synced_snap(dir_root, snap_id, snap_name); - auto &sync_stat = m_snap_sync_stats.at(dir_root); - sync_stat.sync_bytes = 0; } void set_current_syncing_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.current_syncing_snap = std::make_pair(snap_id, snap_name); + _reset_sync_stat(dir_root); //reset counters at the start of every snap sync } void clear_current_syncing_snap(const std::string &dir_root) { std::scoped_lock locker(m_lock); @@ -457,13 +467,26 @@ private: sync_stat.last_synced = clock::now(); sync_stat.last_sync_duration = duration; sync_stat.last_sync_bytes = sync_stat.sync_bytes; + sync_stat.last_sync_files = sync_stat.sync_files; ++sync_stat.synced_snap_count; + _reset_sync_stat(dir_root); } void inc_sync_bytes(const std::string &dir_root, const uint64_t& b) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.sync_bytes += b; } + void inc_sync_files(const std::string &dir_root) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.sync_files++; + } + void inc_total_bytes_files(const std::string &dir_root, const uint64_t& b) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.total_bytes += b; + sync_stat.total_files++; + } bool should_backoff(const std::string &dir_root, int *retval) { if (m_fs_mirror->is_blocklisted()) { *retval = -EBLOCKLISTED; @@ -608,6 +631,9 @@ private: uint64_t set_datasync_files_per_batch(uint64_t value) { return datasync_files_per_batch.exchange(value, std::memory_order_relaxed); } + + // format routines for peer_status + static std::string format_bytes(double bytes); }; } // namespace mirror From d593ad8aefe34dbaffcde22efc294500ca6f7f43 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 28 Mar 2026 15:42:43 +0530 Subject: [PATCH 213/596] tools/cephfs_mirror: Add crawl-state and sync-mode metric The 'crawl' and 'sync-mode' metric is added. sync-mode: full/delta, "crawl": { "state": "completed", "duration": "37s" } sync-mode: --------- The 'sync-mode: full/delta' is added to peer status. The 'delta' means, blockdiff along with snapdiff is being used to sync the files where as 'full' means full directory is crawled and each file is synced entirely. crawl: ----- The state can be in-progress/completed. This identifies whether the crawler thread is done queuing the files for data sync threads. The time taken for the duration is also shown. If the crawl is in-progress, the duration would show the time taken till then from the start of the crawl. If the crawl state is completed, then duration indicates total time taken for the crawl. The crawl duration is shown in "d h m s" format. The existing 'sync_duration' in last_synced_snap is also formatted The values are as below. When crawl state is completed, the 'total_files' metric doesn't grow anymore. crawl_duration: -------------- The crawl_duration of last snapshot is saved in last_synced_snap section as well. Sample outputs: --------------- { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "crawl": { "state": "in-progress", "duration": "21s" }, "bytes": { "sync_bytes": "149.25 MiB", "total_bytes": "176.47 MiB", "sync_percent": "84.57%" }, "files": { "sync_files": 4931, "total_files": 5845, "sync_percent": "84.36%" } }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } ------------------------------------------ { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "crawl": { "state": "completed", "duration": "37s" }, "bytes": { "sync_bytes": "891.39 MiB", "total_bytes": "901.52 MiB", "sync_percent": "98.88%" }, "files": { "sync_files": 29656, "total_files": 30000, "sync_percent": "98.85%" } }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } --------- { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 3, "name": "d0_snap1", "sync-mode": "delta", "crawl": { "state": "completed", "duration": "15s" }, "bytes": { "sync_bytes": "120.20 MiB", "total_bytes": "149.94 MiB", "sync_percent": "80.16%" }, "files": { "sync_files": 4032, "total_files": 5000, "sync_percent": "80.64%" } }, "last_synced_snap": { "id": 2, "name": "d0_snap0", "crawl_duration": "17s", "sync_duration": 45, "sync_time_stamp": "5642.805770s", "sync_bytes": "300.85 MiB", "sync_files": 10000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } ------------- { "/d0": { "state": "idle", "last_synced_snap": { "id": 2, "name": "d0_snap0", "crawl_duration": "17s", "sync_duration": "2m 38s", "sync_time_stamp": "9259.225009s", "sync_bytes": "901.52 MiB", "sync_files": 30000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 89 +++++++++++++++++++++---- src/tools/cephfs_mirror/PeerReplayer.h | 35 ++++++++-- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index ebadde544f8..57a6cd7b75d 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -30,6 +30,7 @@ << m_peer.uuid << ") " << __func__ using namespace std; +using sec_duration = std::chrono::duration; // Performance Counters enum { @@ -1421,12 +1422,16 @@ bool PeerReplayer::SyncMechanism::has_pending_work() const { return true; } -void PeerReplayer::SyncMechanism::mark_crawl_finished(int ret) { - std::unique_lock lock(sdq_lock); - m_crawl_finished = true; - if (ret < 0) - m_crawl_error = true; - sdq_cv.notify_all(); +void PeerReplayer::SyncMechanism::mark_crawl_finished(int ret, double crawl_duration_secs) { + { + std::unique_lock lock(sdq_lock); + m_crawl_finished = true; + if (ret < 0) + m_crawl_error = true; + sdq_cv.notify_all(); + } + // for crawl-state metrics + m_peer_replayer.set_crawl_finished(m_dir_root, true, crawl_duration_secs); } // Returns false if there is any error during data sync @@ -1752,7 +1757,7 @@ int PeerReplayer::SnapDiffSync::get_changed_blocks(const std::string &epath, return r; } -void PeerReplayer::SnapDiffSync::finish_crawl(int ret) { +void PeerReplayer::SnapDiffSync::finish_crawl(int ret, double crawl_duration_secs) { dout(20) << dendl; while (!m_sync_stack.empty()) { @@ -1768,7 +1773,7 @@ void PeerReplayer::SnapDiffSync::finish_crawl(int ret) { } // Crawl and entry operations are done syncing here. So mark crawl finished here - mark_crawl_finished(ret); + mark_crawl_finished(ret, crawl_duration_secs); } PeerReplayer::RemoteSync::RemoteSync(PeerReplayer& peer_replayer, std::string_view dir_root, @@ -1905,7 +1910,7 @@ int PeerReplayer::RemoteSync::get_entry(std::string *epath, struct ceph_statx *s return 0; } -void PeerReplayer::RemoteSync::finish_crawl(int ret) { +void PeerReplayer::RemoteSync::finish_crawl(int ret, double crawl_duration_secs) { dout(20) << dendl; while (!m_sync_stack.empty()) { @@ -1921,7 +1926,7 @@ void PeerReplayer::RemoteSync::finish_crawl(int ret) { } // Crawl and entry operations are done syncing here. So mark stack finished here - mark_crawl_finished(ret); + mark_crawl_finished(ret, crawl_duration_secs); } int PeerReplayer::do_synchronize(const std::string &dir_root, const Snapshot ¤t, @@ -1951,9 +1956,11 @@ int PeerReplayer::do_synchronize(const std::string &dir_root, const Snapshot &cu if (fh.p_mnt == m_local_mount) { syncm = std::make_shared(*this, dir_root, m_local_mount, m_remote_mount, &fh, m_peer, current, prev); + set_snapdiff(dir_root, true); //for stats } else { syncm = std::make_shared(*this, dir_root, m_local_mount, m_remote_mount, &fh, m_peer, current, boost::none); + set_snapdiff(dir_root, false); //for stats } r = syncm->init_sync(); @@ -1968,6 +1975,9 @@ int PeerReplayer::do_synchronize(const std::string &dir_root, const Snapshot &cu // starting from this point we shouldn't care about manual closing of fh.c_fd, // it will be closed automatically when bound tdirp is closed. + sec_duration crawl_duration_time{0}; + auto crawl_start_time = clock::now(); + set_crawl_start_time(dir_root); while (true) { if (should_backoff(dir_root, &r)) { dout(0) << ": backing off r=" << r << dendl; @@ -1996,7 +2006,9 @@ int PeerReplayer::do_synchronize(const std::string &dir_root, const Snapshot &cu } } - syncm->finish_crawl(r); + auto crawl_end_time = clock::now(); + crawl_duration_time = sec_duration(crawl_end_time - crawl_start_time); + syncm->finish_crawl(r, crawl_duration_time.count()); dout(20) << " cur:" << fh.c_fd << " prev:" << fh.p_fd @@ -2524,6 +2536,40 @@ void PeerReplayer::run_datasync(SnapshotDataSyncThread *data_replayer) { } // outer while } +std::string PeerReplayer::format_time(double total_seconds_d) { + // Round to nearest second + uint64_t total_seconds = static_cast(std::llround(total_seconds_d)); + + uint64_t days = total_seconds / 86400; + total_seconds %= 86400; + + uint64_t hours = total_seconds / 3600; + total_seconds %= 3600; + + uint64_t minutes = total_seconds / 60; + uint64_t seconds = total_seconds % 60; + + std::ostringstream oss; + + if (days > 0) { + oss << days << "d " + << std::setw(2) << std::setfill('0') << hours << "h " + << std::setw(2) << std::setfill('0') << minutes << "m " + << std::setw(2) << std::setfill('0') << seconds << "s"; + } else if (hours > 0) { + oss << hours << "h " + << std::setw(2) << std::setfill('0') << minutes << "m " + << std::setw(2) << std::setfill('0') << seconds << "s"; + } else if (minutes > 0) { + oss << minutes << "m " + << std::setw(2) << std::setfill('0') << seconds << "s"; + } else { + oss << seconds << "s"; + } + + return oss.str(); +} + std::string PeerReplayer::format_bytes(double bytes) { static constexpr double KiB = 1024.0; static constexpr double MiB = KiB * 1024.0; @@ -2567,6 +2613,22 @@ void PeerReplayer::peer_status(Formatter *f) { f->open_object_section("current_syncing_snap"); f->dump_unsigned("id", (*sync_stat.current_syncing_snap).first); f->dump_string("name", (*sync_stat.current_syncing_snap).second); + if (sync_stat.snapdiff) + f->dump_string("sync-mode", "delta"); + else + f->dump_string("sync-mode", "full"); + f->open_object_section("crawl"); + if (sync_stat.crawl_finished) { + f->dump_string("state", "completed"); + f->dump_string("duration", format_time(sync_stat.crawl_duration)); + } else { + f->dump_string("state", "in-progress"); + auto cur_time = clock::now(); + sec_duration crawl_duration_till_now{0}; + crawl_duration_till_now = sec_duration(cur_time - sync_stat.crawl_start_time); + f->dump_string("duration", format_time(crawl_duration_till_now.count())); + } + f->close_section(); //crawl f->open_object_section("bytes"); f->dump_string("sync_bytes", format_bytes(sync_stat.sync_bytes)); f->dump_string("total_bytes", format_bytes(sync_stat.total_bytes)); @@ -2593,8 +2655,11 @@ void PeerReplayer::peer_status(Formatter *f) { f->open_object_section("last_synced_snap"); f->dump_unsigned("id", (*sync_stat.last_synced_snap).first); f->dump_string("name", (*sync_stat.last_synced_snap).second); + if (sync_stat.last_sync_crawl_duration) { + f->dump_string("crawl_duration", format_time(*sync_stat.last_sync_crawl_duration)); + } if (sync_stat.last_sync_duration) { - f->dump_float("sync_duration", *sync_stat.last_sync_duration); + f->dump_string("sync_duration", format_time(*sync_stat.last_sync_duration)); f->dump_stream("sync_time_stamp") << sync_stat.last_synced; } if (sync_stat.last_sync_bytes) { diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index bb5e467da53..34786c08f33 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -221,12 +221,12 @@ private: const struct ceph_statx &stx, bool sync_check, const std::function &callback); - virtual void finish_crawl(int ret) = 0; + virtual void finish_crawl(int ret, double crawl_duration_secs) = 0; void push_dataq_entry(PeerReplayer::SyncEntry e); bool pop_dataq_entry(PeerReplayer::SyncEntry &out); bool has_pending_work() const; - void mark_crawl_finished(int ret); + void mark_crawl_finished(int ret, double crawl_duration_secs); bool is_dataq_empty_unlocked() const { return m_sync_dataq.empty(); } @@ -335,7 +335,7 @@ private: const std::function &dirsync_func, const std::function &purge_func); - void finish_crawl(int ret); + void finish_crawl(int ret, double crawl_duration_secs); }; class SnapDiffSync : public SyncMechanism { @@ -355,7 +355,7 @@ private: const struct ceph_statx &stx, bool sync_check, const std::function &callback); - void finish_crawl(int ret); + void finish_crawl(int ret, double crawl_duration_secs); private: int init_directory(const std::string &epath, @@ -384,12 +384,17 @@ private: uint64_t renamed_snap_count = 0; monotime last_synced = clock::zero(); boost::optional last_sync_duration; + boost::optional last_sync_crawl_duration; boost::optional last_sync_bytes; //last sync bytes for display in status boost::optional last_sync_files; //last num of sync files for display in status uint64_t sync_bytes = 0; //sync bytes counter, independently for each directory sync. uint64_t total_bytes = 0; //total bytes counter, independently for each directory sync. uint64_t sync_files = 0; //sync files counter, independently for each directory sync. uint64_t total_files = 0; //total files counter, independently for each directory sync. + bool snapdiff = false; // RemoteSync/Snapdiff aka full/delta + bool crawl_finished = false; // crawl_state - in-progress/completed + clock::time_point crawl_start_time; // to show current crawl duration if crawl is in progress + double crawl_duration = 0.0; // time taken to complete the crawl, includes a few entry operation like mkdir as well }; void _inc_failed_count(const std::string &dir_root) { @@ -425,6 +430,10 @@ private: sync_stat.total_bytes = 0; sync_stat.sync_files = 0; sync_stat.total_files = 0; + sync_stat.snapdiff = false; + sync_stat.crawl_finished = false; + sync_stat.crawl_start_time = clock::now(); + sync_stat.crawl_duration = 0.0; } void _set_last_synced_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { @@ -466,11 +475,23 @@ private: auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.last_synced = clock::now(); sync_stat.last_sync_duration = duration; + sync_stat.last_sync_crawl_duration = sync_stat.crawl_duration; sync_stat.last_sync_bytes = sync_stat.sync_bytes; sync_stat.last_sync_files = sync_stat.sync_files; ++sync_stat.synced_snap_count; _reset_sync_stat(dir_root); } + void set_snapdiff(const std::string &dir_root, bool snapdiff) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.snapdiff = snapdiff; + } + void set_crawl_finished(const std::string &dir_root, bool state, double seconds) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.crawl_finished = state; + sync_stat.crawl_duration = seconds; + } void inc_sync_bytes(const std::string &dir_root, const uint64_t& b) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); @@ -481,6 +502,11 @@ private: auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.sync_files++; } + void set_crawl_start_time(const std::string &dir_root) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.crawl_start_time = clock::now(); + } void inc_total_bytes_files(const std::string &dir_root, const uint64_t& b) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); @@ -634,6 +660,7 @@ private: // format routines for peer_status static std::string format_bytes(double bytes); + static std::string format_time(double total_seconds); }; } // namespace mirror From 4294819d9535145ea5e4cf7082fcbae7de95987c Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 28 Mar 2026 16:27:02 +0530 Subject: [PATCH 214/596] tools/cephfs_mirror: Add read/write throughput The read throughput added measures the bytes read per second from the source ceph filesystem. Similarly, the write throughput added measures the bytes written per second to the remote ceph filesystem. It's derived from the time spent in preadv and pwritev calls. Sample output: ------------- { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "avg_read_throughput_bytes": "12.69 MiB/s", "avg_write_throughput_bytes": "54.49 MiB/s", "crawl": { "state": "completed", "duration": "1s" }, "bytes": { "sync_bytes": "149.94 MiB", "total_bytes": "149.94 MiB", "sync_percent": "100.00%" }, "files": { "sync_files": 5000, "total_files": 5000, "sync_percent": "100.00%" } }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } ------------- Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 24 ++++++++++++++++++++++++ src/tools/cephfs_mirror/PeerReplayer.h | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 57a6cd7b75d..fecc82ea087 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -703,6 +703,10 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string void *ptr; struct iovec iov[NR_IOVECS]; + uint64_t bytes_read = 0; + uint64_t bytes_written = 0; + sec_duration read_time{0}; + sec_duration write_time{0}; int r = ceph_openat(m_local_mount, fh.c_fd, epath.c_str(), O_RDONLY | O_NOFOLLOW, 0); if (r < 0) { derr << ": failed to open local file path=" << epath << ": " @@ -765,12 +769,16 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string } } + auto rs = clock::now(); r = ceph_preadv(m_local_mount, l_fd, iov, num_buffers, offset); + auto re = clock::now(); + read_time += sec_duration(re - rs); if (r < 0) { derr << ": failed to read local file path=" << epath << ": " << cpp_strerror(r) << dendl; break; } + bytes_read += r; dout(10) << ": read: " << r << " bytes" << dendl; if (r == 0) { break; @@ -784,12 +792,16 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string } dout(10) << ": writing to offset: " << offset << dendl; + auto ws = clock::now(); r = ceph_pwritev(m_remote_mount, r_fd, iov, iovs, offset); + auto we = clock::now(); + write_time += sec_duration(we - ws); if (r < 0) { derr << ": failed to write remote file path=" << epath << ": " << cpp_strerror(r) << dendl; break; } + bytes_written += r; offset += r; } @@ -798,6 +810,9 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string ++b; } + //io accounting for metrics + add_io(dir_root, bytes_read, bytes_written, read_time.count(), write_time.count()); + if (num_blocks == 0 && r >= 0) { // handle blocklist case dout(20) << ": truncating epath=" << epath << " to " << stx.stx_size << " bytes" << dendl; @@ -2617,6 +2632,15 @@ void PeerReplayer::peer_status(Formatter *f) { f->dump_string("sync-mode", "delta"); else f->dump_string("sync-mode", "full"); + + //avg read/write throughput + double read_bps = sync_stat.read_time_sec > 0 ? + sync_stat.bytes_read / sync_stat.read_time_sec : 0; + double write_bps = sync_stat.write_time_sec > 0 ? + sync_stat.bytes_written / sync_stat.write_time_sec : 0; + f->dump_string("avg_read_throughput_bytes", format_bytes(read_bps) + "/s"); + f->dump_string("avg_write_throughput_bytes", format_bytes(write_bps) + "/s"); + f->open_object_section("crawl"); if (sync_stat.crawl_finished) { f->dump_string("state", "completed"); diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 34786c08f33..c20da7962e0 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -395,6 +395,11 @@ private: bool crawl_finished = false; // crawl_state - in-progress/completed clock::time_point crawl_start_time; // to show current crawl duration if crawl is in progress double crawl_duration = 0.0; // time taken to complete the crawl, includes a few entry operation like mkdir as well + // actual io accounting + uint64_t bytes_read = 0; //actual bytes read counter, independently for each directory sync. + uint64_t bytes_written = 0; //actual bytes written counter, independently for each directory sync. + double read_time_sec = 0.0; //actual read time in seconds counter, independently for each directroy sync. + double write_time_sec = 0.0; //actual write time in seconds counter, independently for each directroy sync. }; void _inc_failed_count(const std::string &dir_root) { @@ -434,6 +439,10 @@ private: sync_stat.crawl_finished = false; sync_stat.crawl_start_time = clock::now(); sync_stat.crawl_duration = 0.0; + sync_stat.bytes_read = 0; + sync_stat.bytes_written = 0; + sync_stat.read_time_sec = 0.0; + sync_stat.write_time_sec = 0.0; } void _set_last_synced_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { @@ -492,6 +501,15 @@ private: sync_stat.crawl_finished = state; sync_stat.crawl_duration = seconds; } + void add_io(const std::string &dir_root, const uint64_t& br, const uint64_t bw, + const double rt, const double wt) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.bytes_read += br; + sync_stat.bytes_written += bw; + sync_stat.read_time_sec += rt; + sync_stat.write_time_sec += wt; + } void inc_sync_bytes(const std::string &dir_root, const uint64_t& b) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); From 0ae1ade4bc269a368bf29b059b1f20336ef79b2e Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 28 Mar 2026 16:53:33 +0530 Subject: [PATCH 215/596] tools/cephfs_mirror: Add eta metrics Add estimate time of completion for the current syncing snapshot. The calculation takes into account the average read/write throughput from the start of snapshot sync and not the current read/write throughput. So the ETA is affected accordingly. Sample output: ------------- { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "avg_read_throughput_bytes": "3.28 MiB/s", "avg_write_throughput_bytes": "71.03 MiB/s", "crawl": { "state": "completed", "duration": "1s" }, "bytes": { "sync_bytes": "2.31 MiB", "total_bytes": "149.94 MiB", "sync_percent": "1.54%" }, "files": { "sync_files": 67, "total_files": 5000, "sync_percent": "1.34%" }, "eta": "calculating..." }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } ------------------------------------------ { "/d0": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "avg_read_throughput_bytes": "12.17 MiB/s", "avg_write_throughput_bytes": "66.46 MiB/s", "crawl": { "state": "completed", "duration": "1s" }, "bytes": { "sync_bytes": "26.64 MiB", "total_bytes": "149.94 MiB", "sync_percent": "17.77%" }, "files": { "sync_files": 892, "total_files": 5000, "sync_percent": "17.84%" }, "eta": "10s" }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 105 +++++++++++++++++++++++- src/tools/cephfs_mirror/PeerReplayer.h | 34 ++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index fecc82ea087..334567b0c9d 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -707,6 +707,9 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string uint64_t bytes_written = 0; sec_duration read_time{0}; sec_duration write_time{0}; + uint64_t discovered_delta = 0; + + inc_files_started(dir_root); int r = ceph_openat(m_local_mount, fh.c_fd, epath.c_str(), O_RDONLY | O_NOFOLLOW, 0); if (r < 0) { derr << ": failed to open local file path=" << epath << ": " @@ -734,6 +737,8 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string while (num_blocks > 0) { auto offset = b->offset; auto len = b->len; + discovered_delta += b->len; + inc_delta_bytes(dir_root, discovered_delta); dout(10) << ": dir_root=" << dir_root << ", epath=" << epath << ", block: [" << offset << "~" << len << "]" << dendl; @@ -802,6 +807,7 @@ int PeerReplayer::copy_to_remote(const std::string &dir_root, const std::string break; } bytes_written += r; + inc_actual_sync_bytes(dir_root, r); offset += r; } @@ -1726,8 +1732,20 @@ int PeerReplayer::SnapDiffSync::get_changed_blocks(const std::string &epath, dout(20) << ": dir_root=" << m_dir_root << ", epath=" << epath << ", sync_check=" << sync_check << dendl; + using clock = std::chrono::steady_clock; + using seconds = std::chrono::duration; + seconds blockdiff_time{0}; + uint64_t bd_synced_bytes = 0; + if (!sync_check || stx.stx_size <= m_peer_replayer.get_blockdiff_min_file_size()) { - return SyncMechanism::get_changed_blocks(epath, stx, sync_check, callback); + auto bd_s = clock::now(); + int r = SyncMechanism::get_changed_blocks(epath, stx, sync_check, callback); + auto bd_e = clock::now(); + blockdiff_time = seconds(bd_e - bd_s); + bd_synced_bytes = stx.stx_size; + if ( r == 0 ) + m_peer_replayer.set_blockdiff_metrics(m_dir_root, bd_synced_bytes, blockdiff_time.count()); + return r; } ceph_file_blockdiff_info info; @@ -1740,10 +1758,18 @@ int PeerReplayer::SnapDiffSync::get_changed_blocks(const std::string &epath, if (r < 0) { dout(20) << ": new file epath=" << epath << dendl; - return SyncMechanism::get_changed_blocks(epath, stx, sync_check, callback); + auto bd_s = clock::now(); + int r = SyncMechanism::get_changed_blocks(epath, stx, sync_check, callback); + auto bd_e = clock::now(); + blockdiff_time = seconds(bd_e - bd_s); + bd_synced_bytes = stx.stx_size; + if ( r == 0 ) + m_peer_replayer.set_blockdiff_metrics(m_dir_root, bd_synced_bytes, blockdiff_time.count()); + return r; } r = 1; + auto bd_s = clock::now(); while (true) { ceph_file_blockdiff_changedblocks blocks; r = ceph_file_blockdiff(&info, &blocks); @@ -1754,12 +1780,19 @@ int PeerReplayer::SnapDiffSync::get_changed_blocks(const std::string &epath, int rr = r; if (blocks.num_blocks) { + auto bd_num_blocks = blocks.num_blocks; + auto bd_cblock = blocks.b; r = callback(blocks.num_blocks, blocks.b); ceph_free_file_blockdiff_buffer(&blocks); if (r < 0) { derr << ": blockdiff callback returned error: r=" << r << dendl; break; } + while(bd_num_blocks > 0) { + bd_synced_bytes += bd_cblock->len; + --bd_num_blocks; + bd_cblock++; + } } if (rr == 0) { @@ -1767,6 +1800,11 @@ int PeerReplayer::SnapDiffSync::get_changed_blocks(const std::string &epath, } // else fetch next changed blocks } + // blockdiff throughput + auto bd_e = clock::now(); + blockdiff_time = seconds(bd_e - bd_s); + if ( r == 0 ) + m_peer_replayer.set_blockdiff_metrics(m_dir_root, bd_synced_bytes, blockdiff_time.count()); ceph_file_blockdiff_finish(&info); return r; @@ -2611,6 +2649,64 @@ std::string PeerReplayer::format_bytes(double bytes) { return out.str(); }; +double PeerReplayer::compute_eta(PeerReplayer::SnapSyncStat& sync_stat) { + // mlock is held by the caller + + static constexpr uint64_t MIN_FILES_SAMPLE = 25; + static constexpr uint64_t MIN_BYTES_SAMPLE = 1ULL * 16 * 1024 * 1024; // 16MiB + + double read_time = sync_stat.read_time_sec; + double write_time = sync_stat.write_time_sec; + double bytes_read = sync_stat.bytes_read; + double bytes_written = sync_stat.bytes_written; + uint64_t files_started = sync_stat.files_started; + uint64_t files_synced = sync_stat.sync_files; + double read_bps = read_time > 0 ? bytes_read / read_time : 0; + double write_bps = write_time > 0 ? bytes_written / write_time : 0; + uint64_t discovered_delta_bytes = sync_stat.discovered_delta_bytes; + uint64_t synced_bytes = sync_stat.actual_sync_bytes; + uint64_t file_synced_bytes = sync_stat.sync_bytes; + uint64_t total_files = sync_stat.total_files; + + if (read_time == 0 || write_time == 0) + return -1.0; //Calculating + if (files_synced < MIN_FILES_SAMPLE && file_synced_bytes < MIN_BYTES_SAMPLE) + return -1.0; //Calculating + + + if (sync_stat.snapdiff) { + /* blockdiff : + * - total number of files with delta to be synced is known + * - delta of each file is unknown, the avg delta per file is estimated + * - effective bandwidth should accommodate blockdiff time + * - effective bandwidth is based on cumulative synced bytes and time taken, so + * it considers total average past performance and hence it is mostly pessimistic + */ + double avg_delta_per_file = (double)discovered_delta_bytes / (double)files_started; + double estimated_total_delta = avg_delta_per_file * total_files; + double effective_bw = (double) sync_stat.blockdiff_sync_bytes / sync_stat.blockdiff_time_sec; + + uint64_t remaining = + estimated_total_delta > synced_bytes + ? static_cast(estimated_total_delta - synced_bytes) + : 0; + + if (effective_bw <= 0) + return -1.0; + return remaining / effective_bw; + } else { + /* full sync : + * - effective bandwidth is mostly dependant on read and write, again + * it's cumulative, so prediction is based on average past performace. + */ + uint64_t remaining = sync_stat.total_bytes - synced_bytes; + double effective_bw = std::min(read_bps, write_bps); + if (effective_bw <= 0) + return -1.0; //Calculating + return remaining / effective_bw; + } +} + void PeerReplayer::peer_status(Formatter *f) { std::scoped_lock locker(m_lock); f->open_object_section("stats"); @@ -2673,6 +2769,11 @@ void PeerReplayer::peer_status(Formatter *f) { f->dump_string("sync_percent", os.str()); } f->close_section(); //files + double eta = compute_eta(sync_stat); + if (eta == -1.0) + f->dump_string("eta", "calculating..."); + else + f->dump_string("eta", format_time(compute_eta(sync_stat))); f->close_section(); //current_syncing_snap } if (sync_stat.last_synced_snap) { diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index c20da7962e0..9cf3df07150 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -400,6 +400,13 @@ private: uint64_t bytes_written = 0; //actual bytes written counter, independently for each directory sync. double read_time_sec = 0.0; //actual read time in seconds counter, independently for each directroy sync. double write_time_sec = 0.0; //actual write time in seconds counter, independently for each directroy sync. + // eta related + // files_started will be ahead of sync_files as it's incremented just after delta discovery + uint64_t files_started = 0; //files picked up for sync counter, independently for each directory sync. + uint64_t actual_sync_bytes = 0; //actual bytes synced using delta, counter, independently for each directory sync. + uint64_t discovered_delta_bytes = 0; //discovered delta bytes counter, independently for each directory sync. + uint64_t blockdiff_sync_bytes = 0; //actual bytes synced using SnapDiff/blockdiff counter + double blockdiff_time_sec = 0.0; //actual sync time using SnapDiff/blockdiff counter }; void _inc_failed_count(const std::string &dir_root) { @@ -443,6 +450,11 @@ private: sync_stat.bytes_written = 0; sync_stat.read_time_sec = 0.0; sync_stat.write_time_sec = 0.0; + sync_stat.files_started = 0; + sync_stat.actual_sync_bytes = 0; + sync_stat.discovered_delta_bytes = 0; + sync_stat.blockdiff_sync_bytes = 0; + sync_stat.blockdiff_time_sec = 0.0; } void _set_last_synced_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { @@ -501,6 +513,12 @@ private: sync_stat.crawl_finished = state; sync_stat.crawl_duration = seconds; } + void set_blockdiff_metrics(const std::string &dir_root, const uint64_t bd_syncbytes, const double bd_time) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.blockdiff_sync_bytes += bd_syncbytes; + sync_stat.blockdiff_time_sec += bd_time; + } void add_io(const std::string &dir_root, const uint64_t& br, const uint64_t bw, const double rt, const double wt) { std::scoped_lock locker(m_lock); @@ -510,11 +528,26 @@ private: sync_stat.read_time_sec += rt; sync_stat.write_time_sec += wt; } + void inc_delta_bytes(const std::string &dir_root, const uint64_t& b) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.discovered_delta_bytes += b; + } void inc_sync_bytes(const std::string &dir_root, const uint64_t& b) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.sync_bytes += b; } + void inc_actual_sync_bytes(const std::string &dir_root, const uint64_t& b) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.actual_sync_bytes += b; + } + void inc_files_started(const std::string &dir_root) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.files_started++; + } void inc_sync_files(const std::string &dir_root) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); @@ -679,6 +712,7 @@ private: // format routines for peer_status static std::string format_bytes(double bytes); static std::string format_time(double total_seconds); + static double compute_eta(SnapSyncStat& sync_stat); }; } // namespace mirror From 66c05f080b222897e3d28703de008e71f9721053 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Fri, 8 May 2026 05:52:59 +0530 Subject: [PATCH 216/596] tools/cephfs_mirror: Add datasync_queue_wait_duration metric Add the metric which measures the time spent by the snapshot in the data queue waiting for the datasync threads. Sample output: When still 'waiting' in queue { "/d1": { "state": "syncing", "current_syncing_snap": { "id": 18, "name": "d1_snap5", "sync-mode": "delta", "avg_read_throughput_bytes": "0.00 B/s", "avg_write_throughput_bytes": "0.00 B/s", "crawl": { "state": "in-progress", "duration": "13s" }, "datasync_queue_wait": { "state": "waiting", "duration": "12s" }, "bytes": { "sync_bytes": "0.00 B", "total_bytes": "110.99 MiB", "sync_percent": "0.00%" }, "files": { "sync_files": 0, "total_files": 3719, "sync_percent": "0.00%" }, "eta": "calculating..." }, "last_synced_snap": { "id": 15, "name": "d1_snap4" }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 }, } --------------- After 'complete' { "/d1": { "state": "syncing", "current_syncing_snap": { "id": 18, "name": "d1_snap5", "sync-mode": "delta", "avg_read_throughput_bytes": "11.66 MiB/s", "avg_write_throughput_bytes": "34.55 MiB/s", "crawl": { "state": "completed", "duration": "17s" }, "datasync_queue_wait": { "state": "completed", "duration": "19s" }, "bytes": { "sync_bytes": "149.94 MiB", "total_bytes": "149.94 MiB", "sync_percent": "100.00%" }, "files": { "sync_files": 5000, "total_files": 5000, "sync_percent": "100.00%" }, "eta": "0s" }, "last_synced_snap": { "id": 15, "name": "d1_snap4" }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } ----- Also stored in last_sync_snap section { "/d1": { "state": "idle", "last_synced_snap": { "id": 18, "name": "d1_snap5", "crawl_duration": "17s", "datasync_queue_wait_duration": "19s", "sync_duration": "44s", "sync_time_stamp": "8172.009480s", "sync_bytes": "149.94 MiB", "sync_files": 5000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 35 ++++++++++++++++++++++++- src/tools/cephfs_mirror/PeerReplayer.h | 25 ++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 334567b0c9d..11d4c7f67fa 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -1353,10 +1353,17 @@ PeerReplayer::SyncMechanism::~SyncMechanism() { void PeerReplayer::SyncMechanism::push_dataq_entry(SyncEntry e) { dout(10) << ": snapshot data replayer dataq pushed" << " syncm=" << this << " epath=" << e.epath << dendl; - m_peer_replayer.inc_total_bytes_files(std::string(m_dir_root), e.stx.stx_size); + const uint64_t entry_bytes = e.stx.stx_size; std::unique_lock lock(sdq_lock); + if (!m_first_dataq_push_time) { + m_first_dataq_push_time = clock::now(); + m_peer_replayer.set_datasync_queue_wait_start_time(std::string(m_dir_root), + *m_first_dataq_push_time); + } m_sync_dataq.push(std::move(e)); sdq_cv.notify_all(); + lock.unlock(); + m_peer_replayer.inc_total_bytes_files(std::string(m_dir_root), entry_bytes); } bool PeerReplayer::SyncMechanism::pop_dataq_entry(SyncEntry &out_entry) { @@ -1402,6 +1409,12 @@ bool PeerReplayer::SyncMechanism::pop_dataq_entry(SyncEntry &out_entry) { return false; // no more work } + if (!m_datasync_queue_wait_reported && m_first_dataq_push_time) { + sec_duration dq_wait{0}; + dq_wait = sec_duration(clock::now() - *m_first_dataq_push_time); + m_peer_replayer.set_datasync_queue_wait_duration(std::string(m_dir_root), dq_wait.count()); + m_datasync_queue_wait_reported = true; + } out_entry = std::move(m_sync_dataq.front()); m_sync_dataq.pop(); dout(10) << ": snapshot data replayer dataq popped" << " syncm=" << this @@ -2749,6 +2762,22 @@ void PeerReplayer::peer_status(Formatter *f) { f->dump_string("duration", format_time(crawl_duration_till_now.count())); } f->close_section(); //crawl + if (sync_stat.datasync_queue_wait_duration || + sync_stat.datasync_queue_wait_start_time) { + f->open_object_section("datasync_queue_wait"); + if (sync_stat.datasync_queue_wait_duration) { + f->dump_string("state", "completed"); + f->dump_string("duration", + format_time(*sync_stat.datasync_queue_wait_duration)); + } else { + f->dump_string("state", "waiting"); + auto cur_time = clock::now(); + sec_duration dq_wait{0}; + dq_wait = sec_duration(cur_time - *sync_stat.datasync_queue_wait_start_time); + f->dump_string("duration", format_time(dq_wait.count())); + } + f->close_section(); + } f->open_object_section("bytes"); f->dump_string("sync_bytes", format_bytes(sync_stat.sync_bytes)); f->dump_string("total_bytes", format_bytes(sync_stat.total_bytes)); @@ -2783,6 +2812,10 @@ void PeerReplayer::peer_status(Formatter *f) { if (sync_stat.last_sync_crawl_duration) { f->dump_string("crawl_duration", format_time(*sync_stat.last_sync_crawl_duration)); } + if (sync_stat.last_sync_datasync_queue_wait_duration) { + f->dump_string("datasync_queue_wait_duration", + format_time(*sync_stat.last_sync_datasync_queue_wait_duration)); + } if (sync_stat.last_sync_duration) { f->dump_string("sync_duration", format_time(*sync_stat.last_sync_duration)); f->dump_stream("sync_time_stamp") << sync_stat.last_synced; diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 9cf3df07150..6bfc84a5835 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -319,6 +319,8 @@ private: bool m_datasync_error = false; int m_datasync_errno = 0; bool m_backoff = false; + boost::optional m_first_dataq_push_time; + bool m_datasync_queue_wait_reported = false; }; class RemoteSync : public SyncMechanism { @@ -385,6 +387,7 @@ private: monotime last_synced = clock::zero(); boost::optional last_sync_duration; boost::optional last_sync_crawl_duration; + boost::optional last_sync_datasync_queue_wait_duration; boost::optional last_sync_bytes; //last sync bytes for display in status boost::optional last_sync_files; //last num of sync files for display in status uint64_t sync_bytes = 0; //sync bytes counter, independently for each directory sync. @@ -407,6 +410,8 @@ private: uint64_t discovered_delta_bytes = 0; //discovered delta bytes counter, independently for each directory sync. uint64_t blockdiff_sync_bytes = 0; //actual bytes synced using SnapDiff/blockdiff counter double blockdiff_time_sec = 0.0; //actual sync time using SnapDiff/blockdiff counter + boost::optional datasync_queue_wait_duration; // first data_q push to first pop (final) + boost::optional datasync_queue_wait_start_time; // until first pop; for in-progress display }; void _inc_failed_count(const std::string &dir_root) { @@ -455,6 +460,8 @@ private: sync_stat.discovered_delta_bytes = 0; sync_stat.blockdiff_sync_bytes = 0; sync_stat.blockdiff_time_sec = 0.0; + sync_stat.datasync_queue_wait_duration = boost::none; + sync_stat.datasync_queue_wait_start_time = boost::none; } void _set_last_synced_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { @@ -497,6 +504,9 @@ private: sync_stat.last_synced = clock::now(); sync_stat.last_sync_duration = duration; sync_stat.last_sync_crawl_duration = sync_stat.crawl_duration; + //For empty snapshot sync, datasync_queue_wait_duration is 0 + sync_stat.last_sync_datasync_queue_wait_duration = + sync_stat.datasync_queue_wait_duration.value_or(0.0); sync_stat.last_sync_bytes = sync_stat.sync_bytes; sync_stat.last_sync_files = sync_stat.sync_files; ++sync_stat.synced_snap_count; @@ -513,6 +523,21 @@ private: sync_stat.crawl_finished = state; sync_stat.crawl_duration = seconds; } + void set_datasync_queue_wait_start_time(const std::string &dir_root, monotime t) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + if (!sync_stat.datasync_queue_wait_start_time && !sync_stat.datasync_queue_wait_duration) { + sync_stat.datasync_queue_wait_start_time = t; + } + } + void set_datasync_queue_wait_duration(const std::string &dir_root, double seconds) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + if (!sync_stat.datasync_queue_wait_duration) { + sync_stat.datasync_queue_wait_duration = seconds; + sync_stat.datasync_queue_wait_start_time = boost::none; + } + } void set_blockdiff_metrics(const std::string &dir_root, const uint64_t bd_syncbytes, const double bd_time) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); From 2b325537171c04375145f9e0e5fbd439a09eb873 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Fri, 5 Jun 2026 19:53:14 +0530 Subject: [PATCH 217/596] tools/cephfs_mirror: Nest peer_status metrics by dir path and peer uuid Restructure peer_status output so mirrored directory paths can be shared by multiple peers without key collisions. Metrics are grouped as metrics//peer// instead of flat dir keys. Sample output: -------------- 1. When two dirs are syncing. { "metrics": { "/parent/d0": { "peer": { "8a85ab25-70f9-48e9-b82d-56324e75209b": { "state": "syncing", "current_syncing_snap": { "id": 2, "name": "d0_snap0", "sync-mode": "full", "avg_read_throughput_bytes": "9.01 MiB/s", "avg_write_throughput_bytes": "26.74 MiB/s", "crawl": { "state": "completed", "duration": "2s" }, "datasync_queue_wait": { "state": "completed", "duration": "0s" }, "bytes": { "sync_bytes": "60.83 MiB", "total_bytes": "149.94 MiB", "sync_percent": "40.57%" }, "files": { "sync_files": 2028, "total_files": 5000, "sync_percent": "40.56%" }, "eta": "10s" }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } }, "/parent/d1": { "peer": { "8a85ab25-70f9-48e9-b82d-56324e75209b": { "state": "syncing", "current_syncing_snap": { "id": 3, "name": "d1_snap0", "sync-mode": "full", "avg_read_throughput_bytes": "6.80 MiB/s", "avg_write_throughput_bytes": "20.04 MiB/s", "crawl": { "state": "in-progress", "duration": "2s" }, "datasync_queue_wait": { "state": "completed", "duration": "1s" }, "bytes": { "sync_bytes": "4.12 MiB", "total_bytes": "124.98 MiB", "sync_percent": "3.30%" }, "files": { "sync_files": 125, "total_files": 4189, "sync_percent": "2.98%" }, "eta": "18s" }, "snaps_synced": 0, "snaps_deleted": 0, "snaps_renamed": 0 } } } } } --------- 2. When two directories are synced ------------------------------------------ { "metrics": { "/parent/d0": { "peer": { "8a85ab25-70f9-48e9-b82d-56324e75209b": { "state": "idle", "last_synced_snap": { "id": 2, "name": "d0_snap0", "crawl_duration": "2s", "datasync_queue_wait_duration": "0s", "sync_duration": "30s", "sync_time_stamp": "422538.254127s", "sync_bytes": "149.94 MiB", "sync_files": 5000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } }, "/parent/d1": { "peer": { "8a85ab25-70f9-48e9-b82d-56324e75209b": { "state": "idle", "last_synced_snap": { "id": 3, "name": "d1_snap0", "crawl_duration": "2s", "datasync_queue_wait_duration": "1s", "sync_duration": "33s", "sync_time_stamp": "422546.205798s", "sync_bytes": "149.94 MiB", "sync_files": 5000 }, "snaps_synced": 1, "snaps_deleted": 0, "snaps_renamed": 0 } } } } } Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 226 +++++++++++++----------- src/tools/cephfs_mirror/PeerReplayer.h | 3 +- 2 files changed, 120 insertions(+), 109 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 11d4c7f67fa..7ee7e06b757 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -2662,7 +2662,7 @@ std::string PeerReplayer::format_bytes(double bytes) { return out.str(); }; -double PeerReplayer::compute_eta(PeerReplayer::SnapSyncStat& sync_stat) { +double PeerReplayer::compute_eta(const PeerReplayer::SnapSyncStat& sync_stat) { // mlock is held by the caller static constexpr uint64_t MIN_FILES_SAMPLE = 25; @@ -2720,119 +2720,129 @@ double PeerReplayer::compute_eta(PeerReplayer::SnapSyncStat& sync_stat) { } } -void PeerReplayer::peer_status(Formatter *f) { - std::scoped_lock locker(m_lock); - f->open_object_section("stats"); - for (auto &[dir_root, sync_stat] : m_snap_sync_stats) { - f->open_object_section(dir_root); - if (sync_stat.failed) { - f->dump_string("state", "failed"); - if (sync_stat.last_failed_reason) { - f->dump_string("failure_reason", *sync_stat.last_failed_reason); - } - } else if (!sync_stat.current_syncing_snap) { - f->dump_string("state", "idle"); - } else { - f->dump_string("state", "syncing"); - f->open_object_section("current_syncing_snap"); - f->dump_unsigned("id", (*sync_stat.current_syncing_snap).first); - f->dump_string("name", (*sync_stat.current_syncing_snap).second); - if (sync_stat.snapdiff) - f->dump_string("sync-mode", "delta"); - else - f->dump_string("sync-mode", "full"); - - //avg read/write throughput - double read_bps = sync_stat.read_time_sec > 0 ? - sync_stat.bytes_read / sync_stat.read_time_sec : 0; - double write_bps = sync_stat.write_time_sec > 0 ? - sync_stat.bytes_written / sync_stat.write_time_sec : 0; - f->dump_string("avg_read_throughput_bytes", format_bytes(read_bps) + "/s"); - f->dump_string("avg_write_throughput_bytes", format_bytes(write_bps) + "/s"); - - f->open_object_section("crawl"); - if (sync_stat.crawl_finished) { - f->dump_string("state", "completed"); - f->dump_string("duration", format_time(sync_stat.crawl_duration)); - } else { - f->dump_string("state", "in-progress"); - auto cur_time = clock::now(); - sec_duration crawl_duration_till_now{0}; - crawl_duration_till_now = sec_duration(cur_time - sync_stat.crawl_start_time); - f->dump_string("duration", format_time(crawl_duration_till_now.count())); - } - f->close_section(); //crawl - if (sync_stat.datasync_queue_wait_duration || - sync_stat.datasync_queue_wait_start_time) { - f->open_object_section("datasync_queue_wait"); - if (sync_stat.datasync_queue_wait_duration) { - f->dump_string("state", "completed"); - f->dump_string("duration", - format_time(*sync_stat.datasync_queue_wait_duration)); - } else { - f->dump_string("state", "waiting"); - auto cur_time = clock::now(); - sec_duration dq_wait{0}; - dq_wait = sec_duration(cur_time - *sync_stat.datasync_queue_wait_start_time); - f->dump_string("duration", format_time(dq_wait.count())); - } - f->close_section(); - } - f->open_object_section("bytes"); - f->dump_string("sync_bytes", format_bytes(sync_stat.sync_bytes)); - f->dump_string("total_bytes", format_bytes(sync_stat.total_bytes)); - if (sync_stat.total_bytes > 0) { - double sync_pct = (static_cast(sync_stat.sync_bytes) * 100.0) / sync_stat.total_bytes; - std::ostringstream os; - os << std::fixed << std::setprecision(2) << sync_pct << "%"; - f->dump_string("sync_percent", os.str()); - } - f->close_section(); //bytes - f->open_object_section("files"); - f->dump_unsigned("sync_files", sync_stat.sync_files); - f->dump_unsigned("total_files", sync_stat.total_files); - if (sync_stat.total_files > 0) { - double sync_file_pct = (static_cast(sync_stat.sync_files) * 100.0) / sync_stat.total_files; - std::ostringstream os; - os << std::fixed << std::setprecision(2) << sync_file_pct << "%"; - f->dump_string("sync_percent", os.str()); - } - f->close_section(); //files - double eta = compute_eta(sync_stat); - if (eta == -1.0) - f->dump_string("eta", "calculating..."); - else - f->dump_string("eta", format_time(compute_eta(sync_stat))); - f->close_section(); //current_syncing_snap +void PeerReplayer::dump_sync_stat(Formatter *f, const SnapSyncStat &sync_stat) { + if (sync_stat.failed) { + f->dump_string("state", "failed"); + if (sync_stat.last_failed_reason) { + f->dump_string("failure_reason", *sync_stat.last_failed_reason); } - if (sync_stat.last_synced_snap) { - f->open_object_section("last_synced_snap"); - f->dump_unsigned("id", (*sync_stat.last_synced_snap).first); - f->dump_string("name", (*sync_stat.last_synced_snap).second); - if (sync_stat.last_sync_crawl_duration) { - f->dump_string("crawl_duration", format_time(*sync_stat.last_sync_crawl_duration)); - } - if (sync_stat.last_sync_datasync_queue_wait_duration) { - f->dump_string("datasync_queue_wait_duration", - format_time(*sync_stat.last_sync_datasync_queue_wait_duration)); - } - if (sync_stat.last_sync_duration) { - f->dump_string("sync_duration", format_time(*sync_stat.last_sync_duration)); - f->dump_stream("sync_time_stamp") << sync_stat.last_synced; - } - if (sync_stat.last_sync_bytes) { - f->dump_string("sync_bytes", format_bytes(*sync_stat.last_sync_bytes)); - } - if (sync_stat.last_sync_files) { - f->dump_unsigned("sync_files", *sync_stat.last_sync_files); + } else if (!sync_stat.current_syncing_snap) { + f->dump_string("state", "idle"); + } else { + f->dump_string("state", "syncing"); + f->open_object_section("current_syncing_snap"); + f->dump_unsigned("id", (*sync_stat.current_syncing_snap).first); + f->dump_string("name", (*sync_stat.current_syncing_snap).second); + if (sync_stat.snapdiff) + f->dump_string("sync-mode", "delta"); + else + f->dump_string("sync-mode", "full"); + + //avg read/write throughput + double read_bps = sync_stat.read_time_sec > 0 ? + sync_stat.bytes_read / sync_stat.read_time_sec : 0; + double write_bps = sync_stat.write_time_sec > 0 ? + sync_stat.bytes_written / sync_stat.write_time_sec : 0; + f->dump_string("avg_read_throughput_bytes", format_bytes(read_bps) + "/s"); + f->dump_string("avg_write_throughput_bytes", format_bytes(write_bps) + "/s"); + + f->open_object_section("crawl"); + if (sync_stat.crawl_finished) { + f->dump_string("state", "completed"); + f->dump_string("duration", format_time(sync_stat.crawl_duration)); + } else { + f->dump_string("state", "in-progress"); + auto cur_time = clock::now(); + sec_duration crawl_duration_till_now{0}; + crawl_duration_till_now = sec_duration(cur_time - sync_stat.crawl_start_time); + f->dump_string("duration", format_time(crawl_duration_till_now.count())); + } + f->close_section(); //crawl + if (sync_stat.datasync_queue_wait_duration || + sync_stat.datasync_queue_wait_start_time) { + f->open_object_section("datasync_queue_wait"); + if (sync_stat.datasync_queue_wait_duration) { + f->dump_string("state", "completed"); + f->dump_string("duration", + format_time(*sync_stat.datasync_queue_wait_duration)); + } else { + f->dump_string("state", "waiting"); + auto cur_time = clock::now(); + sec_duration dq_wait{0}; + dq_wait = sec_duration(cur_time - *sync_stat.datasync_queue_wait_start_time); + f->dump_string("duration", format_time(dq_wait.count())); } f->close_section(); } - f->dump_unsigned("snaps_synced", sync_stat.synced_snap_count); - f->dump_unsigned("snaps_deleted", sync_stat.deleted_snap_count); - f->dump_unsigned("snaps_renamed", sync_stat.renamed_snap_count); + f->open_object_section("bytes"); + f->dump_string("sync_bytes", format_bytes(sync_stat.sync_bytes)); + f->dump_string("total_bytes", format_bytes(sync_stat.total_bytes)); + if (sync_stat.total_bytes > 0) { + double sync_pct = (static_cast(sync_stat.sync_bytes) * 100.0) / sync_stat.total_bytes; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_pct << "%"; + f->dump_string("sync_percent", os.str()); + } + f->close_section(); //bytes + f->open_object_section("files"); + f->dump_unsigned("sync_files", sync_stat.sync_files); + f->dump_unsigned("total_files", sync_stat.total_files); + if (sync_stat.total_files > 0) { + double sync_file_pct = (static_cast(sync_stat.sync_files) * 100.0) / sync_stat.total_files; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_file_pct << "%"; + f->dump_string("sync_percent", os.str()); + } + f->close_section(); //files + double eta = compute_eta(sync_stat); + if (eta == -1.0) + f->dump_string("eta", "calculating..."); + else + f->dump_string("eta", format_time(compute_eta(sync_stat))); + f->close_section(); //current_syncing_snap + } + if (sync_stat.last_synced_snap) { + f->open_object_section("last_synced_snap"); + f->dump_unsigned("id", (*sync_stat.last_synced_snap).first); + f->dump_string("name", (*sync_stat.last_synced_snap).second); + if (sync_stat.last_sync_crawl_duration) { + f->dump_string("crawl_duration", format_time(*sync_stat.last_sync_crawl_duration)); + } + if (sync_stat.last_sync_datasync_queue_wait_duration) { + f->dump_string("datasync_queue_wait_duration", + format_time(*sync_stat.last_sync_datasync_queue_wait_duration)); + } + if (sync_stat.last_sync_duration) { + f->dump_string("sync_duration", format_time(*sync_stat.last_sync_duration)); + f->dump_stream("sync_time_stamp") << sync_stat.last_synced; + } + if (sync_stat.last_sync_bytes) { + f->dump_string("sync_bytes", format_bytes(*sync_stat.last_sync_bytes)); + } + if (sync_stat.last_sync_files) { + f->dump_unsigned("sync_files", *sync_stat.last_sync_files); + } + f->close_section(); + } + f->dump_unsigned("snaps_synced", sync_stat.synced_snap_count); + f->dump_unsigned("snaps_deleted", sync_stat.deleted_snap_count); + f->dump_unsigned("snaps_renamed", sync_stat.renamed_snap_count); +} + +void PeerReplayer::peer_status(Formatter *f) { + std::scoped_lock locker(m_lock); + f->open_object_section("stats"); + f->open_object_section("metrics"); + for (auto &[dir_root, sync_stat] : m_snap_sync_stats) { + f->open_object_section(dir_root); + f->open_object_section("peer"); + f->open_object_section(m_peer.uuid); + dump_sync_stat(f, sync_stat); + f->close_section(); // peer uuid + f->close_section(); // peer f->close_section(); // dir_root } + f->close_section(); // metrics f->close_section(); // stats } diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 6bfc84a5835..abe95c6dfe8 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -735,9 +735,10 @@ private: } // format routines for peer_status + static void dump_sync_stat(Formatter *f, const SnapSyncStat &sync_stat); static std::string format_bytes(double bytes); static std::string format_time(double total_seconds); - static double compute_eta(SnapSyncStat& sync_stat); + static double compute_eta(const SnapSyncStat& sync_stat); }; } // namespace mirror From 75093b138e33afb11c8548abb8fb2c01e307b9db Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 25 May 2026 23:04:57 +0530 Subject: [PATCH 218/596] qa: Fix the mirroring tests with new nested peer_status output Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- qa/tasks/cephfs/test_mirroring.py | 73 ++++++++++++++++++------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/qa/tasks/cephfs/test_mirroring.py b/qa/tasks/cephfs/test_mirroring.py index dbe6dfb1fa7..e5f7ac27884 100644 --- a/qa/tasks/cephfs/test_mirroring.py +++ b/qa/tasks/cephfs/test_mirroring.py @@ -259,6 +259,9 @@ class TestMirroring(CephFSTestCase): self.assertEqual(peer['stats']['failure_count'], 1) self.assertEqual(peer['stats']['recovery_count'], 1) + def peer_dir_status(self, res, dir_name, peer_uuid): + return res['metrics'][dir_name]['peer'][peer_uuid] + @retry_assert(timeout=60, interval=3) def check_peer_status_empty(self, fs_name, fs_id, peer_spec): peer_uuid = self.get_peer_uuid(peer_spec) @@ -266,7 +269,7 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) try: - self.assertFalse(res) + self.assertFalse(res.get('metrics')) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -279,9 +282,9 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) try: - self.assertTrue(dir_name in res) - self.assertTrue(res[dir_name]['last_synced_snap']['name'] == expected_snap_name) - self.assertTrue(res[dir_name]['snaps_synced'] == expected_snap_count) + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertTrue(dir_stat['last_synced_snap']['name'] == expected_snap_name) + self.assertTrue(dir_stat['snaps_synced'] == expected_snap_count) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -294,10 +297,10 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) try: - self.assertTrue(dir_name in res) - self.assertTrue('idle' == res[dir_name]['state']) - self.assertTrue(expected_snap_name == res[dir_name]['last_synced_snap']['name']) - self.assertTrue(expected_snap_count == res[dir_name]['snaps_synced']) + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertTrue('idle' == dir_stat['state']) + self.assertTrue(expected_snap_name == dir_stat['last_synced_snap']['name']) + self.assertTrue(expected_snap_count == dir_stat['snaps_synced']) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -310,8 +313,8 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) try: - self.assertTrue(dir_name in res) - self.assertTrue(res[dir_name]['snaps_deleted'] == expected_delete_count) + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertTrue(dir_stat['snaps_deleted'] == expected_delete_count) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -324,8 +327,8 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) try: - self.assertTrue(dir_name in res) - self.assertTrue(res[dir_name]['snaps_renamed'] == expected_rename_count) + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertTrue(dir_stat['snaps_renamed'] == expected_rename_count) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -339,8 +342,9 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) - self.assertTrue('syncing' == res[dir_name]['state']) - self.assertTrue(res[dir_name]['current_syncing_snap']['name'] == snap_name) + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertTrue('syncing' == dir_stat['state']) + self.assertTrue(dir_stat['current_syncing_snap']['name'] == snap_name) except RETRY_EXCEPTIONS as e: e.res = res raise @@ -364,7 +368,7 @@ class TestMirroring(CephFSTestCase): res = self.mirror_daemon_command(f'peer status for fs: {fs_name}', 'fs', 'mirror', 'peer', 'status', f'{fs_name}@{fs_id}', peer_uuid) - self.assertTrue('failed' == res[dir_name]['state']) + self.assertTrue('failed' == self.peer_dir_status(res, dir_name, peer_uuid)['state']) def get_peer_uuid(self, peer_spec): status = self.fs.status() @@ -1451,8 +1455,9 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{self.primary_fs_name}@{self.primary_fs_id}', peer_uuid) - if ('syncing' == res["/d0"]['state'] and 'syncing' == res["/d1"]['state'] and \ - 'syncing' == res["/d2"]['state']): + if ('syncing' == self.peer_dir_status(res, '/d0', peer_uuid)['state'] and + 'syncing' == self.peer_dir_status(res, '/d1', peer_uuid)['state'] and + 'syncing' == self.peer_dir_status(res, '/d2', peer_uuid)['state']): break log.debug('removing directory 1') @@ -1585,10 +1590,11 @@ class TestMirroring(CephFSTestCase): res = self.mirror_daemon_command(f'peer status for fs: {self.primary_fs_name}', 'fs', 'mirror', 'peer', 'status', f'{self.primary_fs_name}@{self.primary_fs_id}', peer_uuid) - if('failed' == res[dir_name]['state'] and \ - failure_reason == res.get(dir_name, {}).get('failure_reason', {}) and \ - snap_name == res[dir_name]['last_synced_snap']['name'] and \ - expected_snap_count == res[dir_name]['snaps_synced']): + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + if('failed' == dir_stat['state'] and \ + failure_reason == dir_stat.get('failure_reason', {}) and \ + snap_name == dir_stat['last_synced_snap']['name'] and \ + expected_snap_count == dir_stat['snaps_synced']): break # remove the directory in the remote fs and check status restores to 'idle' self.mount_b.run_shell(['sudo', 'rmdir', remote_snap_path], omit_sudo=False) @@ -1597,9 +1603,10 @@ class TestMirroring(CephFSTestCase): res = self.mirror_daemon_command(f'peer status for fs: {self.primary_fs_name}', 'fs', 'mirror', 'peer', 'status', f'{self.primary_fs_name}@{self.primary_fs_id}', peer_uuid) - if('idle' == res[dir_name]['state'] and 'failure_reason' not in res and \ - snap_name == res[dir_name]['last_synced_snap']['name'] and \ - expected_snap_count == res[dir_name]['snaps_synced']): + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + if('idle' == dir_stat['state'] and 'failure_reason' not in dir_stat and \ + snap_name == dir_stat['last_synced_snap']['name'] and \ + expected_snap_count == dir_stat['snaps_synced']): break self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) @@ -1700,9 +1707,12 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{self.primary_fs_name}@{self.primary_fs_id}', peer_uuid) - d0_sync_time_stamp = float(res["/d0"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) - d1_sync_time_stamp = float(res["/d1"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) - d2_sync_time_stamp = float(res["/d2"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) + d0_sync_time_stamp = float(self.peer_dir_status(res, '/d0', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) + d1_sync_time_stamp = float(self.peer_dir_status(res, '/d1', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) + d2_sync_time_stamp = float(self.peer_dir_status(res, '/d2', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) self.assertGreaterEqual(d1_sync_time_stamp, d0_sync_time_stamp) self.assertGreaterEqual(d2_sync_time_stamp, d0_sync_time_stamp) @@ -1769,9 +1779,12 @@ class TestMirroring(CephFSTestCase): 'fs', 'mirror', 'peer', 'status', f'{self.primary_fs_name}@{self.primary_fs_id}', peer_uuid) - d0_sync_time_stamp = float(res["/d0"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) - d1_sync_time_stamp = float(res["/d1"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) - d2_sync_time_stamp = float(res["/d2"]["last_synced_snap"]["sync_time_stamp"].rstrip('s')) + d0_sync_time_stamp = float(self.peer_dir_status(res, '/d0', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) + d1_sync_time_stamp = float(self.peer_dir_status(res, '/d1', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) + d2_sync_time_stamp = float(self.peer_dir_status(res, '/d2', peer_uuid) + ['last_synced_snap']['sync_time_stamp'].rstrip('s')) self.assertLess(d1_sync_time_stamp, d0_sync_time_stamp) self.assertLess(d2_sync_time_stamp, d0_sync_time_stamp) From 55eceaae3d70d6e0d721bc8fadb0e5b6726257e6 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 25 May 2026 23:52:29 +0530 Subject: [PATCH 219/596] doc: Update the mirroring doc with new metrics fields Update the mirroring documentation and also the release notes with new metrics introduced and it's availability via 'fs mirror peer status' asok interface. Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- PendingReleaseNotes | 5 + doc/cephfs/cephfs-mirroring.rst | 344 ++++++++++++++++++++++++++------ doc/dev/cephfs-mirroring.rst | 126 +++++++++--- 3 files changed, 386 insertions(+), 89 deletions(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index dca256f0dc7..90903a9875c 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -78,6 +78,11 @@ ``ceph fs snapshot mirror daemon status`` now shows the remote cluster's monitor addresses and cluster ID for each configured peer, making it easier to verify peer connectivity and troubleshoot mirroring issues. +* CephFS Mirroring: The ``fs mirror peer status`` admin socket command reports + additional per-directory sync metrics (sync mode, throughput, crawl and + data-sync queue timing, bytes/files progress, and ETA). Output is grouped + under ``metrics//peer/``. For more information, + see https://tracker.ceph.com/issues/73453 * RBD: Mirror snapshot creation and trash purge schedules are now automatically staggered when no explicit "start-time" is specified. This reduces scheduling spikes and distributes work more evenly over time. diff --git a/doc/cephfs/cephfs-mirroring.rst b/doc/cephfs/cephfs-mirroring.rst index cbd51081dfc..5b12ed4dffc 100644 --- a/doc/cephfs/cephfs-mirroring.rst +++ b/doc/cephfs/cephfs-mirroring.rst @@ -356,21 +356,113 @@ command parameter is of format ``filesystem-name@filesystem-id peer-uuid``:: $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok fs mirror peer status cephfs@360 a2dc7784-e7a1-4723-b103-03ee8d8768f8 { - "/d0": { - "state": "idle", - "last_synced_snap": { - "id": 120, - "name": "snap1", - "sync_duration": 3, - "sync_time_stamp": "274900.558797s", - "sync_bytes": 52428800 - }, - "snaps_synced": 2, - "snaps_deleted": 0, - "snaps_renamed": 0 + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "idle", + "last_synced_snap": { + "id": 120, + "name": "snap1", + "crawl_duration": "2s", + "datasync_queue_wait_duration": "1s", + "sync_duration": "33s", + "sync_time_stamp": "274900.558797s", + "sync_bytes": "149.94 MiB", + "sync_files": 5000 + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } +The per-directory fields are nested under ``metrics//peer/`` so +the same directory path can be reported for multiple peers without key collisions. + +.. _cephfs_mirror_peer_status_formatting: + +Value formatting +---------------- + +Several fields in the status output are formatted for readability rather than reported as raw +numbers. The subsections below describe each format; field tables later in this section refer +back to them. + +.. _cephfs_mirror_status_durations: + +Durations +--------- + +Fields: ``crawl_duration``, ``datasync_queue_wait_duration``, ``sync_duration``, +``crawl.duration``, ``datasync_queue_wait.duration``, and ``eta``. + +Elapsed time is rounded to the nearest whole second and displayed as a combination of days, +hours, minutes, and seconds. The format adapts to the magnitude: + +- ``s`` — seconds only, when less than one minute (for example, ``2s`` or ``33s``) +- ``m s`` — minutes and seconds, when less than one hour (for example, ``5m 12s``) +- ``h m s`` — hours, minutes, and seconds, when less than one day + (for example, ``1h 05m 30s``) +- ``d h m s`` — days, hours, minutes, and seconds, when one day or longer + (for example, ``1d 02h 30m 45s``) + +.. _cephfs_mirror_status_data_sizes: + +Data sizes +---------- + +Fields: ``sync_bytes``, ``bytes.sync_bytes``, ``bytes.total_bytes``, and the byte counts in +``last_synced_snap``. + +Byte counts use binary (IEC) units with two decimal places. The unit is chosen automatically +from ``B``, ``KiB``, ``MiB``, ``GiB``, ``TiB``, or ``PiB`` (powers of 1024). For example, +``149.94 MiB``. + +.. _cephfs_mirror_status_throughput: + +Throughput +---------- + +Fields: ``avg_read_throughput_bytes`` and ``avg_write_throughput_bytes``. + +Average transfer rate in bytes per second, using the same binary units as :ref:`data sizes +` with a ``/s`` suffix. For example, ``13.03 MiB/s`` means +13.03 mebibytes per second. + +.. _cephfs_mirror_status_percentages: + +Percentages +----------- + +Fields: ``bytes.sync_percent`` and ``files.sync_percent``. + +Percentage complete with two decimal places and a ``%`` suffix (for example, ``40.29%``). + +.. _cephfs_mirror_status_counts: + +Counts +------ + +Fields: ``sync_files``, ``total_files``, ``snaps_synced``, ``snaps_deleted``, ``snaps_renamed``, +and snapshot ``id``. + +Plain unsigned integers with no unit suffix. + +.. _cephfs_mirror_status_timestamp: + +Timestamp +--------- + +Field: ``sync_time_stamp``. + +Monotonic clock time in seconds (since daemon startup) when the snapshot sync finished, +printed with sub-second precision and an ``s`` suffix (for example, ``274900.558797s``). This +is not a wall-clock or epoch timestamp. + Synchronization stats including ``snaps_synced``, ``snaps_deleted`` and ``snaps_renamed`` are reset on daemon restart and/or when a directory is reassigned to another mirror daemon (when multiple mirror daemons are deployed). @@ -386,27 +478,128 @@ When a directory is currently being synchronized, the mirror daemon marks it as $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok fs mirror peer status cephfs@360 a2dc7784-e7a1-4723-b103-03ee8d8768f8 { - "/d0": { - "state": "syncing", - "current_syncing_snap": { - "id": 121, - "name": "snap2" - }, - "last_synced_snap": { - "id": 120, - "name": "snap1", - "sync_duration": 3, - "sync_time_stamp": "274900.558797s", - "sync_bytes": 52428800 - }, - "snaps_synced": 2, - "snaps_deleted": 0, - "snaps_renamed": 0 + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "syncing", + "current_syncing_snap": { + "id": 121, + "name": "snap2", + "sync-mode": "full", + "avg_read_throughput_bytes": "13.03 MiB/s", + "avg_write_throughput_bytes": "24.24 MiB/s", + "crawl": { + "state": "completed", + "duration": "2s" + }, + "datasync_queue_wait": { + "state": "complete", + "duration": "1s" + }, + "bytes": { + "sync_bytes": "60.40 MiB", + "total_bytes": "149.94 MiB", + "sync_percent": "40.29%" + }, + "files": { + "sync_files": 2013, + "total_files": 5000, + "sync_percent": "40.26%" + }, + "eta": "7s" + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } The mirror daemon marks it back to ``idle``, when the syncing completes. +When ``state`` is ``syncing``, ``current_syncing_snap`` includes the following +progress fields (see :ref:`cephfs_mirror_peer_status_formatting` for how values are +displayed): + +.. list-table:: + :widths: 35 65 + :header-rows: 1 + + * - Field + - Description + * - ``sync-mode`` + - Whether the snapshot is synchronized with a full tree copy (``full``) or incremental snapdiff/blockdiff (``delta``). + * - ``avg_read_throughput_bytes`` + - Average read rate from the primary filesystem for this snapshot sync. See + :ref:`Throughput `. + * - ``avg_write_throughput_bytes`` + - Average write rate to the remote peer for this snapshot sync. See + :ref:`Throughput `. + * - ``crawl.state`` + - Whether the directory tree walk is ``in-progress`` or ``completed``. While + ``in-progress``, ``bytes.total_bytes`` and ``files.total_files`` reflect only what + has been discovered so far and may keep increasing; once ``completed``, those totals + are fully discovered for this snapshot sync. + * - ``crawl.duration`` + - Elapsed crawl time so far, or total crawl time once ``crawl.state`` is ``completed``. + See :ref:`Durations `. + * - ``datasync_queue_wait.state`` + - Whether the snapshot is still ``waiting`` in the data-sync queue or has started transfer (``complete``). + * - ``datasync_queue_wait.duration`` + - Elapsed queue wait time so far, or total queue wait time once transfer has started. + See :ref:`Durations `. + * - ``bytes.sync_bytes`` + - Amount of file data synchronized so far for this snapshot. See + :ref:`Data sizes `. + * - ``bytes.total_bytes`` + - Total file data discovered for this snapshot sync. See + :ref:`Data sizes `. Increases during the crawl while + ``crawl.state`` is ``in-progress``; final once ``crawl.state`` is ``completed``. + * - ``bytes.sync_percent`` + - Percentage of ``total_bytes`` synchronized so far. See + :ref:`Percentages `. + * - ``files.sync_files`` + - Number of files synchronized so far for this snapshot. See + :ref:`Counts `. + * - ``files.total_files`` + - Total number of files discovered for this snapshot sync. See + :ref:`Counts `. Increases during the crawl while + ``crawl.state`` is ``in-progress``; final once ``crawl.state`` is ``completed``. + * - ``files.sync_percent`` + - Percentage of ``total_files`` synchronized so far. See + :ref:`Percentages `. + * - ``eta`` + - Estimated time remaining to finish the snapshot sync, or ``calculating...`` until enough + samples are collected. See :ref:`Durations `. + +``last_synced_snap`` includes these additional fields for the last completed snapshot sync +(see :ref:`cephfs_mirror_peer_status_formatting` for how values are displayed): + +.. list-table:: + :widths: 35 65 + :header-rows: 1 + + * - Field + - Description + * - ``crawl_duration`` + - Total time spent walking the directory tree for that snapshot sync. See + :ref:`Durations `. + * - ``datasync_queue_wait_duration`` + - Total time the snapshot waited in the data-sync queue before file transfer began. See + :ref:`Durations `. + * - ``sync_duration`` + - Total elapsed time for the snapshot sync. See :ref:`Durations `. + * - ``sync_time_stamp`` + - When the sync finished. See :ref:`Timestamp `. + * - ``sync_bytes`` + - Total file data synchronized for that snapshot. See + :ref:`Data sizes `. + * - ``sync_files`` + - Number of files synchronized for that snapshot. See :ref:`Counts `. + When a directory experiences a configured number of consecutive synchronization failures, the mirror daemon marks it as ``failed``. Synchronization for these directories is retried. By default, the number of consecutive failures before a directory is marked as failed @@ -419,24 +612,37 @@ E.g., adding a regular file for synchronization would result in failed status:: $ ceph fs snapshot mirror add cephfs /f0 $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok fs mirror peer status cephfs@360 a2dc7784-e7a1-4723-b103-03ee8d8768f8 { - "/d0": { - "state": "idle", - "last_synced_snap": { - "id": 121, - "name": "snap2", - "sync_duration": 5, - "sync_time_stamp": "500900.600797s", - "sync_bytes": 78643200 + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "idle", + "last_synced_snap": { + "id": 121, + "name": "snap2", + "crawl_duration": "2s", + "datasync_queue_wait_duration": "1s", + "sync_duration": "44s", + "sync_time_stamp": "500900.600797s", + "sync_bytes": "149.94 MiB", + "sync_files": 5000 + }, + "snaps_synced": 3, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } }, - "snaps_synced": 3, - "snaps_deleted": 0, - "snaps_renamed": 0 - }, - "/f0": { - "state": "failed", - "snaps_synced": 0, - "snaps_deleted": 0, - "snaps_renamed": 0 + "/f0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "failed", + "snaps_synced": 0, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } @@ -454,24 +660,38 @@ In the remote filesystem:: $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok fs mirror peer status cephfs@360 a2dc7784-e7a1-4723-b103-03ee8d8768f8 { - "/d0": { - "state": "failed", - "failure_reason": "snapshot 'snap2' has invalid metadata", - "last_synced_snap": { - "id": 120, - "name": "snap1", - "sync_duration": 3, - "sync_time_stamp": "274900.558797s" + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "failed", + "failure_reason": "snapshot 'snap2' has invalid metadata", + "last_synced_snap": { + "id": 120, + "name": "snap1", + "crawl_duration": "2s", + "datasync_queue_wait_duration": "1s", + "sync_duration": "33s", + "sync_time_stamp": "274900.558797s", + "sync_bytes": "149.94 MiB", + "sync_files": 5000 + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } }, - "snaps_synced": 2, - "snaps_deleted": 0, - "snaps_renamed": 0 - }, - "/f0": { - "state": "failed", - "snaps_synced": 0, - "snaps_deleted": 0, - "snaps_renamed": 0 + "/f0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "failed", + "snaps_synced": 0, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } diff --git a/doc/dev/cephfs-mirroring.rst b/doc/dev/cephfs-mirroring.rst index 9fe072967f3..9cba6cce34c 100644 --- a/doc/dev/cephfs-mirroring.rst +++ b/doc/dev/cephfs-mirroring.rst @@ -393,20 +393,35 @@ status. Commands of this kind take the form ``filesystem-name@filesystem-id peer :: { - "/d0": { - "state": "idle", - "last_synced_snap": { - "id": 120, - "name": "snap1", - "sync_duration": 0.079997898999999997, - "sync_time_stamp": "274900.558797s" - }, - "snaps_synced": 2, - "snaps_deleted": 0, - "snaps_renamed": 0 + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "idle", + "last_synced_snap": { + "id": 120, + "name": "snap1", + "crawl_duration": "2s", + "datasync_queue_wait_duration": "1s", + "sync_duration": "33s", + "sync_time_stamp": "274900.558797s", + "sync_bytes": "149.94 MiB", + "sync_files": 5000 + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } +Several fields in the status output are formatted for readability rather than reported as raw +numbers. See :ref:`Value formatting ` in +:doc:`/cephfs/cephfs-mirroring` for duration, data size, throughput, percentage, count, and +timestamp formats. + Synchronization stats such as ``snaps_synced``, ``snaps_deleted`` and ``snaps_renamed`` are reset when the daemon is restarted or (when multiple mirror daemons are deployed), when a directory is reassigned to another mirror @@ -419,6 +434,49 @@ A directory can be in one of the following states:: - `syncing`: The directory is currently being synchronized - `failed`: The directory has hit upper limit of consecutive failures +:: + + { + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "syncing", + "current_syncing_snap": { + "id": 121, + "name": "snap2", + "sync-mode": "full", + "avg_read_throughput_bytes": "13.03 MiB/s", + "avg_write_throughput_bytes": "24.24 MiB/s", + "crawl": { + "state": "completed", + "duration": "2s" + }, + "datasync_queue_wait": { + "state": "complete", + "duration": "1s" + }, + "bytes": { + "sync_bytes": "60.40 MiB", + "total_bytes": "149.94 MiB", + "sync_percent": "40.29%" + }, + "files": { + "sync_files": 2013, + "total_files": 5000, + "sync_percent": "40.26%" + }, + "eta": "7s" + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } + } + } + When a directory hits a configured number of consecutive synchronization failures, the mirror daemon marks it as ``failed``. Synchronization for these directories is retried. By default, the number of consecutive failures before a @@ -439,23 +497,37 @@ status: :: { - "/d0": { - "state": "idle", - "last_synced_snap": { - "id": 120, - "name": "snap1", - "sync_duration": 0.079997898999999997, - "sync_time_stamp": "274900.558797s" + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "idle", + "last_synced_snap": { + "id": 120, + "name": "snap1", + "crawl_duration": "2s", + "datasync_queue_wait_duration": "1s", + "sync_duration": "33s", + "sync_time_stamp": "274900.558797s", + "sync_bytes": "149.94 MiB", + "sync_files": 5000 + }, + "snaps_synced": 2, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } }, - "snaps_synced": 2, - "snaps_deleted": 0, - "snaps_renamed": 0 - }, - "/f0": { - "state": "failed", - "snaps_synced": 0, - "snaps_deleted": 0, - "snaps_renamed": 0 + "/f0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "failed", + "snaps_synced": 0, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } } } From 1d676c2455ace8eb2bda07dd0b8c3057324dc38b Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 26 May 2026 00:14:03 +0530 Subject: [PATCH 220/596] qa: Add mirror metrics testcases Add testcases for newly introduced mirror metrics and validate it via 'fs mirror peer status' asok interface. Fixes: https://tracker.ceph.com/issues/73453 Signed-off-by: Kotresh HR --- qa/tasks/cephfs/test_mirroring.py | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/qa/tasks/cephfs/test_mirroring.py b/qa/tasks/cephfs/test_mirroring.py index e5f7ac27884..1adb16e87d5 100644 --- a/qa/tasks/cephfs/test_mirroring.py +++ b/qa/tasks/cephfs/test_mirroring.py @@ -1,4 +1,5 @@ import os +import re import json import base64 import errno @@ -260,8 +261,62 @@ class TestMirroring(CephFSTestCase): self.assertEqual(peer['stats']['recovery_count'], 1) def peer_dir_status(self, res, dir_name, peer_uuid): + self.assertIn('metrics', res) return res['metrics'][dir_name]['peer'][peer_uuid] + def assert_last_synced_snap_metrics(self, last_synced_snap): + for key in ('crawl_duration', 'datasync_queue_wait_duration', 'sync_duration', + 'sync_time_stamp', 'sync_bytes', 'sync_files'): + self.assertIn(key, last_synced_snap, msg=f'missing last_synced_snap.{key}') + self.assertRegex( + last_synced_snap['sync_bytes'], + r'^\d+(\.\d+)?\s+(B|KiB|MiB|GiB|TiB|PiB)$') + self.assertIsInstance(last_synced_snap['sync_files'], int) + self.assertGreaterEqual(last_synced_snap['sync_files'], 0) + + def assert_syncing_snap_metrics(self, snap, sync_mode=None): + for key in ('sync-mode', 'avg_read_throughput_bytes', 'avg_write_throughput_bytes', + 'crawl', 'bytes', 'files', 'eta'): + self.assertIn(key, snap, msg=f'missing current_syncing_snap.{key}') + if sync_mode is not None: + self.assertEqual(snap['sync-mode'], sync_mode) + self.assertTrue(snap['avg_read_throughput_bytes'].endswith('/s')) + self.assertTrue(snap['avg_write_throughput_bytes'].endswith('/s')) + self.assertIn(snap['crawl']['state'], ('in-progress', 'completed')) + self.assertTrue(snap['crawl']['duration']) + bytes_obj = snap['bytes'] + self.assertIn('sync_bytes', bytes_obj) + self.assertIn('total_bytes', bytes_obj) + if bytes_obj.get('total_bytes') and bytes_obj['total_bytes'] != '0.00 B': + self.assertIn('sync_percent', bytes_obj) + files_obj = snap['files'] + self.assertIn('sync_files', files_obj) + self.assertIn('total_files', files_obj) + if files_obj.get('total_files', 0) > 0: + self.assertIn('sync_percent', files_obj) + self.assertTrue( + snap['eta'] == 'calculating...' or bool(re.search(r'\d', snap['eta']))) + + @retry_assert(timeout=120, interval=1) + def check_peer_syncing_progress_metrics(self, fs_name, fs_id, peer_spec, dir_name, + snap_name, sync_mode=None): + peer_uuid = self.get_peer_uuid(peer_spec) + res = self.mirror_daemon_command(f'peer status for fs: {fs_name}', + 'fs', 'mirror', 'peer', 'status', + f'{fs_name}@{fs_id}', peer_uuid) + try: + dir_stat = self.peer_dir_status(res, dir_name, peer_uuid) + self.assertEqual(dir_stat['state'], 'syncing') + snap = dir_stat['current_syncing_snap'] + self.assertEqual(snap['name'], snap_name) + self.assert_syncing_snap_metrics(snap, sync_mode=sync_mode) + if 'datasync_queue_wait' in snap: + self.assertIn(snap['datasync_queue_wait']['state'], + ('waiting', 'completed')) + except RETRY_EXCEPTIONS as e: + e.res = res + raise + @retry_assert(timeout=60, interval=3) def check_peer_status_empty(self, fs_name, fs_id, peer_spec): peer_uuid = self.get_peer_uuid(peer_spec) @@ -1595,6 +1650,7 @@ class TestMirroring(CephFSTestCase): failure_reason == dir_stat.get('failure_reason', {}) and \ snap_name == dir_stat['last_synced_snap']['name'] and \ expected_snap_count == dir_stat['snaps_synced']): + self.assert_last_synced_snap_metrics(dir_stat['last_synced_snap']) break # remove the directory in the remote fs and check status restores to 'idle' self.mount_b.run_shell(['sudo', 'rmdir', remote_snap_path], omit_sudo=False) @@ -1607,9 +1663,67 @@ class TestMirroring(CephFSTestCase): if('idle' == dir_stat['state'] and 'failure_reason' not in dir_stat and \ snap_name == dir_stat['last_synced_snap']['name'] and \ expected_snap_count == dir_stat['snaps_synced']): + self.assert_last_synced_snap_metrics(dir_stat['last_synced_snap']) break self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + def test_cephfs_mirror_peer_status_last_synced_metrics(self): + """Peer status last_synced_snap reports new sync timing and size metrics.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, self.secondary_fs_name) + + dir_name = 'metrics_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap_name, 1) + + peer_uuid = self.get_peer_uuid(peer_spec) + res = self.mirror_daemon_command(f'peer status for fs: {self.primary_fs_name}', + 'fs', 'mirror', 'peer', 'status', + f'{self.primary_fs_name}@{self.primary_fs_id}', + peer_uuid) + self.assert_last_synced_snap_metrics( + self.peer_dir_status(res, f'/{dir_name}', peer_uuid)['last_synced_snap']) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_cephfs_mirror_peer_status_syncing_progress_metrics(self): + """Peer status current_syncing_snap reports sync progress metrics.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, self.secondary_fs_name) + + dir_name = 'd0' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 3000, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap0 = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap0}']) + self.check_peer_syncing_progress_metrics( + self.primary_fs_name, self.primary_fs_id, peer_spec, f'/{dir_name}', + snap0, sync_mode='full') + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap0, 1) + + self.mount_a.write_n_mb(os.path.join(dir_name, 'file.0'), 1) + self.mount_a.create_n_files(f'{dir_name}/snapdiff_file', 3000, sync=True) + snap1 = 'snap1' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap1}']) + self.check_peer_syncing_progress_metrics( + self.primary_fs_name, self.primary_fs_id, peer_spec, f'/{dir_name}', + snap1, sync_mode='delta') + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap1, 2) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + def test_cephfs_mirror_sync_already_existing_snapshots(self): """ That mirroring syncs the already existing snapshot correctly. From d5da88d349bd0f8f34cbb7968f491a1f50167e58 Mon Sep 17 00:00:00 2001 From: Josh Durgin Date: Fri, 12 Jun 2026 13:34:25 -0700 Subject: [PATCH 221/596] scripts/ceph-release-notes: use raw strings for escaped characters And don't escape literals in strip(). This is required in newer python versions. Signed-off-by: Josh Durgin --- src/script/ceph-release-notes | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/script/ceph-release-notes b/src/script/ceph-release-notes index 78dfc58fd6a..15b33d7d037 100755 --- a/src/script/ceph-release-notes +++ b/src/script/ceph-release-notes @@ -42,14 +42,14 @@ reviewed_by_re = re.compile(r"Rev(.*)By", re.IGNORECASE) labels = {'bluestore', 'build/ops', 'cephfs', 'common', 'core', 'mgr', 'mon', 'performance', 'pybind', 'rdma', 'rgw', 'rbd', 'tests', 'tools'} -merge_re = re.compile("Merge (pull request|PR) #(\d+).*") +merge_re = re.compile(r"Merge (pull request|PR) #(\d+).*") # prefixes is the list of commit description prefixes we recognize prefixes = ['bluestore', 'build/ops', 'cephfs', 'cephx', 'cli', 'cmake', 'common', 'core', 'crush', 'doc', 'fs', 'librados', 'librbd', 'log', 'mds', 'mgr', 'mon', 'msg', 'objecter', 'osd', 'pybind', 'rbd', 'rbd-mirror', 'rbd-nbd', 'rgw', 'tests', 'tools'] -signed_off_re = re.compile("Signed-off-by: (.+) <") -tracker_re = re.compile("http://tracker.ceph.com/issues/(\d+)") +signed_off_re = re.compile(r"Signed-off-by: (.+) <") +tracker_re = re.compile(r"http://tracker.ceph.com/issues/(\d+)") rst_link_re = re.compile(r"([a-zA-Z0-9])_(\W)") release_re = re.compile(r"^(nautilus|octopus|pacific|quincy|reef|squid|tentacle|umbrella):\s*", re.IGNORECASE) @@ -216,14 +216,14 @@ def make_release_notes(gh, repo, ref, cherry_picks, plaintext, html, markdown, v "{sha1}^1..{sha1}^2".format(sha1=commit.hexsha) ): for author in re.findall( - "Signed-off-by:\s*(.*?)\s*<", c.message + r"Signed-off-by:\s*(.*?)\s*<", c.message ): authors[author] = 1 issues.extend(fixes_re.findall(c.message) + tracker_re.findall(c.message)) else: for author in re.findall( - "Signed-off-by:\s*(.*?)\s*<", message + r"Signed-off-by:\s*(.*?)\s*<", message ): if author == "[Your Name]": continue @@ -243,7 +243,7 @@ def make_release_notes(gh, repo, ref, cherry_picks, plaintext, html, markdown, v if strict: title_re = ( - '^(?:nautilus|octopus|pacific|quincy):\s+(' + + r'^(?:nautilus|octopus|pacific|quincy):\s+(' + '|'.join(prefixes) + ')(:.*)' ) @@ -257,10 +257,10 @@ def make_release_notes(gh, repo, ref, cherry_picks, plaintext, html, markdown, v if use_tags: title = split_component(title, gh, number) - title = title.strip(' \t\n\r\f\v\.\,\;\:\-\=') + title = title.strip(' \t\n\r\f\v.,;:-=') # escape asterisks, which is used by reStructuredTextrst for inline # emphasis - title = title.replace('*', '\*') + title = title.replace('*', r'\*') # and escape the underscores for noting a link title = rst_link_re.sub(r'\1\_\2', title) # remove release prefix for backports @@ -326,7 +326,7 @@ def make_release_notes(gh, repo, ref, cherry_picks, plaintext, html, markdown, v ) ) elif markdown: - markdown_title = title.replace('_', '\_').replace('.', '.') + markdown_title = title.replace('_', r'\_').replace('.', '.') print ("- {title} ({issues}[pr#{pr}](https://github.com/ceph/ceph/pull/{pr}), {author})\n".format( title=markdown_title, issues=issues, From 1dda56b1a00a6cbf520d932332ff097716ab256e Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sat, 13 Jun 2026 09:50:09 +0800 Subject: [PATCH 222/596] python-common/cryptotools: stop using the removed X509Req API pyOpenSSL deprecated OpenSSL.crypto.X509Req in 24.2.0 (2024-07-20) and removed it in 26.3.0 (2026-06-12). as we don't pin pyopenssl, CI picked up the new release, and create_self_signed_cert() started failing with: AttributeError: module 'OpenSSL.crypto' has no attribute 'X509Req' this took down run-tox-mgr, run-tox-mgr-dashboard-py3 and the mypy check. we only used X509Req to build a subject name and then copied it into the X509 cert. so drop it, and set the subject on the cert directly. the resulting cert stays the same: subject from dname, issuer set to the same subject, self-signed. Fixes: https://tracker.ceph.com/issues/77391 Signed-off-by: Kefu Chai --- src/python-common/ceph/cryptotools/internal.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/python-common/ceph/cryptotools/internal.py b/src/python-common/ceph/cryptotools/internal.py index 7d6e0a487ec..db3d6a5c048 100644 --- a/src/python-common/ceph/cryptotools/internal.py +++ b/src/python-common/ceph/cryptotools/internal.py @@ -45,19 +45,13 @@ class InternalCryptoCaller(CryptoCaller): ) -> str: _pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey) - # Create a "subject" object - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - req = crypto.X509Req() - subj = req.get_subject() - - # populate the subject with the dname settings + # create a self-signed cert and populate its subject with the dname + # settings + cert = crypto.X509() + subj = cert.get_subject() for k, v in dname.items(): setattr(subj, k, v) - - # create a self-signed cert - cert = crypto.X509() - cert.set_subject(req.get_subject()) + cert.set_subject(subj) cert.set_serial_number(int(uuid4())) cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) # 10 years From ede8e34f61baf53d3bd516c4492c09c433cefbc0 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 12 Jun 2026 18:17:26 +0800 Subject: [PATCH 223/596] cmake: find Protobuf via cmake config before module mode Module-mode FindProtobuf defines only the classic targets, so gRPCConfig.cmake's find_dependency(Protobuf) fails on the partial export set. Prefer config mode; fall back to module mode. Signed-off-by: Sun Yuechi --- src/CMakeLists.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a93a38d7501..552dd7d760d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1023,10 +1023,12 @@ option(WITH_NVMEOF_GATEWAY_MONITOR_CLIENT "build nvmeof gateway monitor client" if(WITH_NVMEOF_GATEWAY_MONITOR_CLIENT) - # Find Protobuf installation - # Looks for protobuf-config.cmake file installed by Protobuf's cmake installation. - option(protobuf_MODULE_COMPATIBLE TRUE) - find_package(Protobuf REQUIRED) + # Prefer config mode so gRPC sees protobuf's full target set + # (incl. upb); fall back to module mode. + find_package(Protobuf CONFIG) + if(NOT Protobuf_FOUND) + find_package(Protobuf REQUIRED) + endif() set(_REFLECTION grpc++_reflection) if(CMAKE_CROSSCOMPILING) From d688bc8f341df846d89f7c119b5528fe367ad66c Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sun, 14 Jun 2026 09:23:39 +0800 Subject: [PATCH 224/596] mgr/dashboard: fix "welcome" page reference in add-host e2e feature 0fb99092ef2 renamed the cypress page-registry key "welcome" to "onboarding" in urls.po.ts and updated 01-create-cluster-welcome.feature, but missed the Background of 02-create-cluster-add-host.feature. so urlsCollection.pages["welcome"] is undefined and the step fails with TypeError: Cannot read properties of undefined (reading 'url') at cypress/e2e/common/global.feature.po.ts:12 this fails the Background of every scenario in the spec, and cascades to the later create-cluster specs that depend on the expanded cluster. update the reference to "onboarding" to match the registry. Signed-off-by: Kefu Chai --- .../orchestrator/workflow/02-create-cluster-add-host.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/02-create-cluster-add-host.feature b/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/02-create-cluster-add-host.feature index 4709f36e9c4..7228f0c67a6 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/02-create-cluster-add-host.feature +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/orchestrator/workflow/02-create-cluster-add-host.feature @@ -6,7 +6,7 @@ Feature: Cluster expansion host addition Background: Cluster expansion wizard Given I am logged in - And I am on the "welcome" page + And I am on the "onboarding" page And I click on "Add Storage" button Scenario Outline: Add hosts From a3dad559e87600b394d80cd51b244b0dcc3f31d9 Mon Sep 17 00:00:00 2001 From: Nitzan Mordechai Date: Sun, 14 Jun 2026 07:55:36 +0000 Subject: [PATCH 225/596] mgr: handle SIGTERM/SIGINT in standby mgr to avoid CEPHADM_FAILED_DAEMON MgrStandby only registered SIGHUP, leaving SIGTERM/SIGINT at OS defaults. When a standby is stopped via `ceph orch daemon stop`, SIGTERM terminates it with exit code 143, causing systemd to mark the unit as failed instead of stopped, which triggers CEPHADM_FAILED_DAEMON. Mirror the handler already used in Mgr::init() for the active mgr. Fixes: https://tracker.ceph.com/issues/77116 Signed-off-by: Nitzan Mordechai --- src/mgr/MgrStandby.cc | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/mgr/MgrStandby.cc b/src/mgr/MgrStandby.cc index b0cfedbcc02..70db2135966 100644 --- a/src/mgr/MgrStandby.cc +++ b/src/mgr/MgrStandby.cc @@ -153,11 +153,18 @@ int MgrStandby::asok_command(std::string_view cmd, const cmdmap_t& cmdmap, Forma } } +static void handle_standby_mgr_signal(int signum) +{ + derr << " *** Got signal " << sig_str(signum) << " ***" << dendl; + _exit(0); +} + int MgrStandby::init() { init_async_signal_handler(); register_async_signal_handler(SIGHUP, sighup_handler); - + register_async_signal_handler_oneshot(SIGTERM, handle_standby_mgr_signal); + register_async_signal_handler_oneshot(SIGINT, handle_standby_mgr_signal); cct->_conf.add_observer(this); std::lock_guard l(lock); @@ -495,6 +502,8 @@ int MgrStandby::main(vector args) // Disable signal handlers unregister_async_signal_handler(SIGHUP, sighup_handler); + unregister_async_signal_handler(SIGTERM, handle_standby_mgr_signal); + unregister_async_signal_handler(SIGINT, handle_standby_mgr_signal); shutdown_async_signal_handler(); return 0; From b57373872265c199ea986cdb259e4664c1cb4149 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 14 Jun 2026 22:29:54 +0800 Subject: [PATCH 226/596] build/script: print sccache stats alongside ccache sccache is enabled via WITH_SCCACHE, but run-make.sh only printed ccache statistics after the build. Print `sccache --show-stats` too when sccache is available. Signed-off-by: Sun Yuechi --- src/script/run-make.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/script/run-make.sh b/src/script/run-make.sh index 30ca35ce274..236af0cf669 100755 --- a/src/script/run-make.sh +++ b/src/script/run-make.sh @@ -190,6 +190,10 @@ function build() { ci_debug "Running cmake" $DRY_RUN cmake --build . $targets -- $BUILD_MAKEOPTS || return 1 $DRY_RUN ccache -s # print the ccache statistics to evaluate the efficiency + # print sccache stats too when built with WITH_SCCACHE + if type sccache > /dev/null 2>&1; then + $DRY_RUN sccache --show-stats + fi } DEFAULT_MAKEOPTS=${DEFAULT_MAKEOPTS:--j$(get_processors)} From 28a2a2c8821cf0fb79c0345a368f0f951d455223 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 15 Jun 2026 00:11:02 +0530 Subject: [PATCH 227/596] qa/cephfs: Add test for duplicate directory acquire notify Verify that reloading the mirroring module and removing a directory does not leave a ghost replayer entry that keeps syncing snapshots. Fixes: https://tracker.ceph.com/issues/77398 Signed-off-by: Kotresh HR --- qa/tasks/cephfs/test_mirroring.py | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/qa/tasks/cephfs/test_mirroring.py b/qa/tasks/cephfs/test_mirroring.py index 74bdb591757..f75f0216112 100644 --- a/qa/tasks/cephfs/test_mirroring.py +++ b/qa/tasks/cephfs/test_mirroring.py @@ -89,6 +89,37 @@ class TestMirroring(CephFSTestCase): def disable_mirroring_module(self): self.run_ceph_cmd("mgr", "module", "disable", TestMirroring.MODULE_NAME) + def is_mirroring_module_enabled(self): + modules = json.loads( + self.get_ceph_cmd_stdout('mgr', 'module', 'ls', '--format', 'json')) + return self.MODULE_NAME in modules.get('enabled_modules', []) + + def wait_mirroring_module_disabled(self): + with safe_while(sleep=1, tries=30, + action='wait for mirroring module disable') as proceed: + while proceed(): + if not self.is_mirroring_module_enabled(): + return + + def wait_mirroring_module_reload(self, fs_name, dir_name): + """Wait for mirroring module enable and directory re-acquire after reload.""" + with safe_while(sleep=2, tries=60, + action='wait for mirroring module reload') as proceed: + while proceed(): + if not self.is_mirroring_module_enabled(): + continue + dirmap = self.mirror_dirmap(fs_name, dir_name) + if dirmap.get('state') == 'mapped': + return + + def wait_directory_mapped(self, fs_name, dir_name): + with safe_while(sleep=2, tries=30, + action='wait for directory to be mapped') as proceed: + while proceed(): + dirmap = self.mirror_dirmap(fs_name, dir_name) + if dirmap.get('state') == 'mapped': + return + def enable_mirroring(self, fs_name, fs_id): res = self.mirror_daemon_command(f'counter dump for fs: {fs_name}', 'counter', 'dump') vbefore = res[TestMirroring.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR][0] @@ -417,6 +448,19 @@ class TestMirroring(CephFSTestCase): log.debug(f'destination snapshot checksum {snap_name} {dest_res}') self.assertTrue(source_res == dest_res) + def mirror_dirmap(self, fs_name, dir_name): + return json.loads(self.get_ceph_cmd_stdout( + 'fs', 'snapshot', 'mirror', 'dirmap', fs_name, dir_name)) + + @retry_assert(timeout=60, interval=5) + def assert_snapshot_not_synced(self, dir_name, snap_name): + """Assert a snapshot on the primary has not appeared on the secondary.""" + try: + snap_list = self.mount_b.ls(path=f'{dir_name}/.snap') + except CommandFailedError: + return + self.assertNotIn(snap_name, snap_list) + @retry_assert(timeout=150, interval=5) def verify_failed_directory(self, fs_name, fs_id, peer_spec, dir_name): peer_uuid = self.get_peer_uuid(peer_spec) @@ -1970,3 +2014,40 @@ class TestMirroring(CephFSTestCase): self.config_set('client.mirror', 'cephfs_mirror_distribute_datasync_threads', 'true') self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_cephfs_mirror_duplicate_acquire_notify(self): + """Duplicate acquire notifies must not leave a ghost replayer directory. + + Disabling and re-enabling the mirroring module reloads FSPolicy from omap + and re-sends acquire for mapped directories. Without an idempotent + PeerReplayer::add_directory(), a duplicate vector entry survives + remove_directory() (only one list entry is erased) and the replayer + keeps syncing after the directory is removed from mirroring. + + Without the fix, a ghost entry may also crash the replayer thread when + pick_directory() calls m_snap_sync_stats.at() after remove erased the map + entry; this test catches spurious sync when that path still runs. + """ + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'dup_acquire_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + self.wait_directory_mapped(self.primary_fs_name, f'/{dir_name}') + + self.disable_mirroring_module() + self.wait_mirroring_module_disabled() + self.enable_mirroring_module() + self.wait_mirroring_module_reload(self.primary_fs_name, f'/{dir_name}') + + self.remove_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = 'snap_after_remove' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.assert_snapshot_not_synced(dir_name, snap_name) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) From e74f11546a1b2b57ce9358c89785bddc843cfe5e Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 15 Jun 2026 00:11:07 +0530 Subject: [PATCH 228/596] tools/cephfs_mirror: Ignore duplicate directory acquire notifications Make PeerReplayer::add_directory() idempotent when the mgr re-sends acquire for a directory already in the replayer list. Fixes: https://tracker.ceph.com/issues/77398 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 7ee7e06b757..93bc04ddaf8 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -357,10 +357,16 @@ void PeerReplayer::shutdown() { void PeerReplayer::add_directory(string_view dir_root) { dout(20) << ": dir_root=" << dir_root << dendl; + auto _dir_root = std::string(dir_root); std::scoped_lock locker(m_lock); - m_directories.emplace_back(dir_root); - m_snap_sync_stats.emplace(dir_root, SnapSyncStat()); + if (std::find(m_directories.begin(), m_directories.end(), _dir_root) != + m_directories.end()) { + dout(10) << ": dir_root=" << _dir_root << " already in replay list" << dendl; + return; + } + m_directories.emplace_back(_dir_root); + m_snap_sync_stats.emplace(_dir_root, SnapSyncStat()); m_cond.notify_all(); } From 056b70613154506c55214e0dda85a4472d1b106e Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 15 Jun 2026 04:45:37 +0800 Subject: [PATCH 229/596] rgw: declare rgw_a's dependency on rgw_schedulers and kmip rgw_a uses rgw::dmclock::* (rgw_schedulers) and kmip_* (kmip) but never declared either, so each consumer relinked them by hand. ld.bfd hid this via lazy archive extraction; mold pulls the members and fails with undefined symbols. Declare it on rgw_a (PRIVATE) and drop the now redundant explicit links from radosgw and the rgw shared library. Signed-off-by: Sun Yuechi --- src/rgw/CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rgw/CMakeLists.txt b/src/rgw/CMakeLists.txt index f0f605d6200..a8082f580ad 100644 --- a/src/rgw/CMakeLists.txt +++ b/src/rgw/CMakeLists.txt @@ -508,6 +508,10 @@ if(COMPILER_SUPPORTS_VLA_ERROR) $<$:-Werror=vla>) endif() +# rgw_a references rgw::dmclock::* (rgw_schedulers) and kmip_* (kmip); declare +# it here so the dependency propagates to every consumer. PRIVATE: not in headers. +target_link_libraries(rgw_a PRIVATE rgw_schedulers kmip) + set(radosgw_srcs rgw_main.cc) @@ -532,8 +536,6 @@ target_include_directories(radosgw SYSTEM PUBLIC "../rapidjson/include") target_link_libraries(radosgw PRIVATE legacy-option-headers ${rgw_libs} - rgw_schedulers - kmip ${ALLOC_LIBS}) install(TARGETS radosgw DESTINATION bin) @@ -642,8 +644,6 @@ target_include_directories(rgw SYSTEM PUBLIC "../rapidjson/include") target_link_libraries(rgw PRIVATE ${rgw_libs} - rgw_schedulers - kmip librados cls_rgw_client cls_otp_client From 13b334507f92e00dc55e2039550260295243c5cc Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Sun, 14 Jun 2026 11:17:27 -0400 Subject: [PATCH 230/596] script/ptl-tool: address datetime warning /home/runner/work/ceph/ceph/src/script/ptl-tool.py:1743: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). Signed-off-by: Patrick Donnelly --- src/script/ptl-tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/ptl-tool.py b/src/script/ptl-tool.py index 762069ca3b5..a9bb69f953f 100755 --- a/src/script/ptl-tool.py +++ b/src/script/ptl-tool.py @@ -1629,7 +1629,7 @@ def manage_qa_tracker(args, R, session, branch, prs, tag, qa_tracker_description log.error(f"Failed to post comment: {r.status_code} {r.text}") elif args.create_qa: - now_str = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H:%M") + now_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d-%H:%M") default_subject = f"{base} integration testing by {USER} started {now_str}" issue_kwargs['subject'] = args.qa_subject if args.qa_subject else default_subject @@ -1740,7 +1740,7 @@ def build_branch(args): args.skip_conflict_check = True # Compute branch names now that integration flags and auto-detect have settled - branch = datetime.datetime.utcnow().strftime(args.branch).format(user=USER) + branch = datetime.datetime.now(datetime.UTC).strftime(args.branch).format(user=USER) if args.branch_release: branch = branch + "-" + args.branch_release if args.branch_append: From 88b9725be03dcd9267f8ab224dfd68be73631810 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Fri, 29 May 2026 10:40:45 -0400 Subject: [PATCH 231/596] script/ptl-tool: source githubmap only when producing credits Assisted-by: Gemini Signed-off-by: Patrick Donnelly --- src/script/ptl-tool.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/script/ptl-tool.py b/src/script/ptl-tool.py index 762069ca3b5..a2d9d781db1 100755 --- a/src/script/ptl-tool.py +++ b/src/script/ptl-tool.py @@ -1675,20 +1675,6 @@ def build_branch(args): G = git.Repo(args.git) - try: - c = resolve_ref(G, 'main', BASE_REMOTE_URL, args.always_fetch) - githubmap_content = G.git.show(f"{c.hexsha}:.githubmap") - comment = re.compile(r"\s*#") - patt = re.compile(r"([\w-]+)\s+(.*)") - for line in githubmap_content.splitlines(): - if comment.match(line): - continue - m = patt.match(line) - if m: - CONTRIBUTORS[m.group(1)] = m.group(2) - except git.exc.GitCommandError as e: - raise SystemExit(f"Could not fetch .githubmap from {BASE_REMOTE_URL}:main:\n{e}") - R = None if args.create_qa or args.update_qa or args.audit or args.final_merge: log.info("connecting to %s", REDMINE_ENDPOINT) @@ -1739,6 +1725,21 @@ def build_branch(args): args.always_fetch = True args.skip_conflict_check = True + if args.credits: + try: + c = resolve_ref(G, 'main', BASE_REMOTE_URL, args.always_fetch) + githubmap_content = G.git.show(f"{c.hexsha}:.githubmap") + comment = re.compile(r"\s*#") + patt = re.compile(r"([\w-]+)\s+(.*)") + for line in githubmap_content.splitlines(): + if comment.match(line): + continue + m = patt.match(line) + if m: + CONTRIBUTORS[m.group(1)] = m.group(2) + except git.exc.GitCommandError as e: + raise SystemExit(f"Could not fetch .githubmap from {BASE_REMOTE_URL}:main:\n{e}") + # Compute branch names now that integration flags and auto-detect have settled branch = datetime.datetime.utcnow().strftime(args.branch).format(user=USER) if args.branch_release: From 648794cce3fdcfad323d3661b75ed15ddc2ffdaa Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Fri, 22 May 2026 10:02:11 -0400 Subject: [PATCH 232/596] script/ptl-tool: do not update PRs for private QA Assisted-by: Gemini Signed-off-by: Patrick Donnelly --- src/script/ptl-tool.py | 70 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/script/ptl-tool.py b/src/script/ptl-tool.py index 762069ca3b5..0591c59b661 100755 --- a/src/script/ptl-tool.py +++ b/src/script/ptl-tool.py @@ -1604,29 +1604,32 @@ def manage_qa_tracker(args, R, session, branch, prs, tag, qa_tracker_description log.error(f"failed to update {issue}") sys.exit(1) - for pr in added_prs: - body = f"This PR has been added to [{issue.subject}]({issue_url})." - if args.dry_run: - log.info(f"[DRY RUN] Would post comment to added PR #{pr}: {body}") - else: - endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" - r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) - if r.status_code == 201: - log.info(f"Successfully posted added comment to PR #{pr}") + if getattr(issue, 'is_private', False): + log.info(f"QA ticket {issue.url} is private. Skipping GitHub PR updates for added/removed PRs.") + else: + for pr in added_prs: + body = f"This PR has been added to [{issue.subject}]({issue_url})." + if args.dry_run: + log.info(f"[DRY RUN] Would post comment to added PR #{pr}: {body}") else: - log.error(f"Failed to post comment: {r.status_code} {r.text}") + endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" + r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) + if r.status_code == 201: + log.info(f"Successfully posted added comment to PR #{pr}") + else: + log.error(f"Failed to post comment: {r.status_code} {r.text}") - for pr in removed_prs: - body = f"This PR has been removed from [{issue.subject}]({issue_url})." - if args.dry_run: - log.info(f"[DRY RUN] Would post comment to removed PR #{pr}: {body}") - else: - endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" - r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) - if r.status_code == 201: - log.info(f"Successfully posted removed comment to PR #{pr}") + for pr in removed_prs: + body = f"This PR has been removed from [{issue.subject}]({issue_url})." + if args.dry_run: + log.info(f"[DRY RUN] Would post comment to removed PR #{pr}: {body}") else: - log.error(f"Failed to post comment: {r.status_code} {r.text}") + endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" + r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) + if r.status_code == 201: + log.info(f"Successfully posted removed comment to PR #{pr}") + else: + log.error(f"Failed to post comment: {r.status_code} {r.text}") elif args.create_qa: now_str = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H:%M") @@ -1642,19 +1645,22 @@ def manage_qa_tracker(args, R, session, branch, prs, tag, qa_tracker_description log.info("created redmine qa issue: %s", issue.url) issue_url = issue.url - for pr in prs: - log.debug(f"Posting QA Run in comment for ={pr}") - subject = issue_kwargs['subject'] - body = f"This PR has been added to [{subject}]({issue_url})." - if args.dry_run: - log.info(f"[DRY RUN] Would post comment to PR #{pr}: {body}") - else: - endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" - r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) - if r.status_code == 201: - log.info(f"Successfully posted comment to PR #{pr}") + if args.qa_private: + log.info("QA ticket is private. Skipping GitHub PR updates.") + else: + for pr in prs: + log.debug(f"Posting QA Run in comment for ={pr}") + subject = issue_kwargs['subject'] + body = f"This PR has been added to [{subject}]({issue_url})." + if args.dry_run: + log.info(f"[DRY RUN] Would post comment to PR #{pr}: {body}") else: - log.error(f"Failed to post comment: {r.status_code} {r.text}") + endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments" + r = session.post(endpoint, auth=GithubBearerAuth(), data=json.dumps({'body':body})) + if r.status_code == 201: + log.info(f"Successfully posted comment to PR #{pr}") + else: + log.error(f"Failed to post comment: {r.status_code} {r.text}") def build_branch(args): base = args.base From 1414770fbb47b8e097885a1f3fb626a82d915488 Mon Sep 17 00:00:00 2001 From: Vallari Agrawal Date: Tue, 16 Jun 2026 00:30:12 +0300 Subject: [PATCH 233/596] mgr/dashboard: bump nvmeof submodule to 1.8.2 Update proto files and gateway submodule Fixes: https://tracker.ceph.com/issues/77422 Signed-off-by: Vallari Agrawal --- src/nvmeof/gateway | 2 +- .../dashboard/services/proto/gateway.proto | 25 + .../dashboard/services/proto/gateway_pb2.py | 603 ++++++++++++------ .../services/proto/gateway_pb2_grpc.py | 68 ++ 4 files changed, 502 insertions(+), 196 deletions(-) diff --git a/src/nvmeof/gateway b/src/nvmeof/gateway index e27436eddac..c862723fe6d 160000 --- a/src/nvmeof/gateway +++ b/src/nvmeof/gateway @@ -1 +1 @@ -Subproject commit e27436eddacf8d4b2eace77c5fbd250adf48a155 +Subproject commit c862723fe6dcc2fd3d5217b7fd4772e5311c352e diff --git a/src/pybind/mgr/dashboard/services/proto/gateway.proto b/src/pybind/mgr/dashboard/services/proto/gateway.proto index 9ab09941741..7ea29a47e1e 100644 --- a/src/pybind/mgr/dashboard/services/proto/gateway.proto +++ b/src/pybind/mgr/dashboard/services/proto/gateway.proto @@ -71,6 +71,9 @@ service Gateway { // Delete a subsystem network rpc del_subsystem_network(del_subsystem_network_req) returns(req_status) {} + // Refresh auto-listeners for all network masks configured on the given subsystem on this gateway + rpc gw_refresh_network(gw_refresh_network_req) returns(gw_refresh_network_status) {} + // Add KMIP server endpoints rpc add_kmip_server_endpoints(add_kmip_server_endpoints_req) returns(req_status) {} @@ -125,6 +128,9 @@ service Gateway { // Removes a host from a subsystem rpc remove_host(remove_host_req) returns (req_status) {} + // Set keep host connected flag + rpc set_keep_host_connected(set_keep_host_connected_req) returns (req_status) {} + // Changes a host inband authentication keys rpc change_host_key(change_host_key_req) returns (req_status) {} @@ -326,6 +332,17 @@ message del_subsystem_network_req { string network_mask = 2; } +message gw_refresh_network_req { + string subsystem_nqn = 1; +} + +message gw_refresh_network_status { + int32 status = 1; + string error_message = 2; + repeated string added = 3; + repeated string removed = 4; +} + message kmip_server_endpoint { string address = 1; optional uint32 port = 2; @@ -395,6 +412,12 @@ message remove_host_req { string subsystem_nqn = 1; string host_nqn = 2; optional bool force = 3; + optional bool keep_connections = 4; +} + +message set_keep_host_connected_req { + string subsystem_nqn = 1; + string host_nqn = 2; } message list_hosts_req { @@ -567,6 +590,7 @@ message namespace { optional string nonce = 7; optional bool auto_visible = 8; repeated string hosts = 9; + optional string rados_namespace_name = 10; } message subsystems_info_cli { @@ -749,6 +773,7 @@ message connection { optional string subsystem = 12; optional bool disconnected_due_to_keepalive_timeout = 13; optional DHCHAPControllerKeyOrigin dhchap_controller_origin = 14; + optional bool host_deleted = 15; } message connections_info { diff --git a/src/pybind/mgr/dashboard/services/proto/gateway_pb2.py b/src/pybind/mgr/dashboard/services/proto/gateway_pb2.py index 834787e03aa..f09cd3a75b0 100644 --- a/src/pybind/mgr/dashboard/services/proto/gateway_pb2.py +++ b/src/pybind/mgr/dashboard/services/proto/gateway_pb2.py @@ -20,7 +20,7 @@ DESCRIPTOR = _descriptor.FileDescriptor( syntax='proto3', serialized_options=b'Z)github.com/ceph/ceph-nvmeof/lib/go/nvmeof', create_key=_descriptor._internal_create_key, - serialized_pb=b'\n&dashboard/services/proto/gateway.proto\"\x84\x06\n\x11namespace_add_req\x12\x15\n\rrbd_pool_name\x18\x01 \x01(\t\x12\x16\n\x0erbd_image_name\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x11\n\x04nsid\x18\x04 \x01(\rH\x00\x88\x01\x01\x12\x12\n\nblock_size\x18\x05 \x01(\r\x12\x11\n\x04uuid\x18\x06 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x61nagrpid\x18\x07 \x01(\x05H\x02\x88\x01\x01\x12\x19\n\x0c\x63reate_image\x18\x08 \x01(\x08H\x03\x88\x01\x01\x12\x11\n\x04size\x18\t \x01(\x04H\x04\x88\x01\x01\x12\x12\n\x05\x66orce\x18\n \x01(\x08H\x05\x88\x01\x01\x12\x1c\n\x0fno_auto_visible\x18\x0b \x01(\x08H\x06\x88\x01\x01\x12\x18\n\x0btrash_image\x18\x0c \x01(\x08H\x07\x88\x01\x01\x12 \n\x13\x64isable_auto_resize\x18\r \x01(\x08H\x08\x88\x01\x01\x12\x16\n\tread_only\x18\x0e \x01(\x08H\t\x88\x01\x01\x12\x1f\n\x12rbd_data_pool_name\x18\x0f \x01(\tH\n\x88\x01\x01\x12\x15\n\x08location\x18\x10 \x01(\tH\x0b\x88\x01\x01\x12!\n\x14rados_namespace_name\x18\x11 \x01(\tH\x0c\x88\x01\x01\x12-\n\x12\x65ncryption_entries\x18\x12 \x03(\x0b\x32\x11.encryption_entry\x12\x37\n\x14\x65ncryption_algorithm\x18\x13 \x01(\x0e\x32\x14.EncryptionAlgorithmH\r\x88\x01\x01\x42\x07\n\x05_nsidB\x07\n\x05_uuidB\x0b\n\t_anagrpidB\x0f\n\r_create_imageB\x07\n\x05_sizeB\x08\n\x06_forceB\x12\n\x10_no_auto_visibleB\x0e\n\x0c_trash_imageB\x16\n\x14_disable_auto_resizeB\x0c\n\n_read_onlyB\x15\n\x13_rbd_data_pool_nameB\x0b\n\t_locationB\x17\n\x15_rados_namespace_nameB\x17\n\x15_encryption_algorithm\"{\n\x14namespace_resize_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08new_size\x18\x04 \x01(\x04\x42\x10\n\x0e_OBSOLETE_uuid\"o\n\x1anamespace_get_io_stats_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuid\"h\n\x1clist_namespaces_io_stats_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04nsid\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x07\n\x05_nsid\"\xee\x02\n\x15namespace_set_qos_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x1e\n\x11rw_ios_per_second\x18\x04 \x01(\x04H\x01\x88\x01\x01\x12!\n\x14rw_mbytes_per_second\x18\x05 \x01(\x04H\x02\x88\x01\x01\x12 \n\x13r_mbytes_per_second\x18\x06 \x01(\x04H\x03\x88\x01\x01\x12 \n\x13w_mbytes_per_second\x18\x07 \x01(\x04H\x04\x88\x01\x01\x12\x12\n\x05\x66orce\x18\x08 \x01(\x08H\x05\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x14\n\x12_rw_ios_per_secondB\x17\n\x15_rw_mbytes_per_secondB\x16\n\x14_r_mbytes_per_secondB\x16\n\x14_w_mbytes_per_secondB\x08\n\x06_force\"\xbe\x01\n)namespace_change_load_balancing_group_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08\x61nagrpid\x18\x04 \x01(\x05\x12\x1a\n\rauto_lb_logic\x18\x05 \x01(\x08H\x01\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x10\n\x0e_auto_lb_logic\"z\n\x1fnamespace_change_visibility_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x14\n\x0c\x61uto_visible\x18\x03 \x01(\x08\x12\x12\n\x05\x66orce\x18\x04 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_force\"h\n\x1dnamespace_change_location_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x15\n\x08location\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_location\"]\n!namespace_set_rbd_trash_image_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x13\n\x0btrash_image\x18\x03 \x01(\x08\"Y\n\x1dnamespace_set_auto_resize_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x13\n\x0b\x61uto_resize\x18\x03 \x01(\x08\"\x8f\x01\n\x14namespace_delete_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x16\n\ti_am_sure\x18\x04 \x01(\x08H\x01\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x0c\n\n_i_am_sure\"m\n\x16namespace_add_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x10\n\x08host_nqn\x18\x03 \x01(\t\x12\x12\n\x05\x66orce\x18\x04 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_force\"R\n\x19namespace_delete_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x10\n\x08host_nqn\x18\x03 \x01(\t\"\xf5\x02\n\x14\x63reate_subsystem_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x15\n\rserial_number\x18\x02 \x01(\t\x12\x1b\n\x0emax_namespaces\x18\x03 \x01(\rH\x00\x88\x01\x01\x12\x11\n\tenable_ha\x18\x04 \x01(\x08\x12\x1c\n\x0fno_group_append\x18\x05 \x01(\x08H\x01\x88\x01\x01\x12\x17\n\ndhchap_key\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rkey_encrypted\x18\x07 \x01(\x08H\x03\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\x08 \x03(\t\x12\x1d\n\x10secure_listeners\x18\t \x01(\x08H\x04\x88\x01\x01\x12\x11\n\x04port\x18\n \x01(\rH\x05\x88\x01\x01\x42\x11\n\x0f_max_namespacesB\x12\n\x10_no_group_appendB\r\n\x0b_dhchap_keyB\x10\n\x0e_key_encryptedB\x13\n\x11_secure_listenersB\x07\n\x05_port\"q\n\x14\x64\x65lete_subsystem_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x12\n\x05\x66orce\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x16\n\ti_am_sure\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x08\n\x06_forceB\x0c\n\n_i_am_sure\"Y\n\x18\x63hange_subsystem_key_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x17\n\ndhchap_key\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_dhchap_key\"H\n\x19\x61\x64\x64_subsystem_network_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x14\n\x0cnetwork_mask\x18\x02 \x01(\t\"H\n\x19\x64\x65l_subsystem_network_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x14\n\x0cnetwork_mask\x18\x02 \x01(\t\"C\n\x14kmip_server_endpoint\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\x04port\x18\x02 \x01(\rH\x00\x88\x01\x01\x42\x07\n\x05_port\"e\n\x18kmip_server_endpoint_cli\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12\x0f\n\x07\x61\x64\x64ress\x18\x03 \x01(\t\x12\x0c\n\x04port\x18\x04 \x01(\r\"u\n\x1d\x61\x64\x64_kmip_server_endpoints_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12(\n\tendpoints\x18\x03 \x03(\x0b\x32\x15.kmip_server_endpoint\"u\n\x1d\x64\x65l_kmip_server_endpoints_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12(\n\tendpoints\x18\x03 \x03(\x0b\x32\x15.kmip_server_endpoint\"x\n\x1elist_kmip_server_endpoints_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bserver_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x0e\n\x0c_server_name\"q\n\x1akmip_server_endpoints_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12,\n\tendpoints\x18\x03 \x03(\x0b\x32\x19.kmip_server_endpoint_cli\"`\n\x13list_namespaces_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x11\n\x04nsid\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x11\n\x04uuid\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nsidB\x07\n\x05_uuid\"\xc3\x02\n\x0c\x61\x64\x64_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x10\n\x03psk\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ndhchap_key\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x1a\n\rpsk_encrypted\x18\x05 \x01(\x08H\x02\x88\x01\x01\x12\x1a\n\rkey_encrypted\x18\x06 \x01(\x08H\x03\x88\x01\x01\x12\x1d\n\x10\x64hchap_ctrlr_key\x18\x07 \x01(\tH\x04\x88\x01\x01\x12 \n\x13\x63trlr_key_encrypted\x18\x08 \x01(\x08H\x05\x88\x01\x01\x42\x06\n\x04_pskB\r\n\x0b_dhchap_keyB\x10\n\x0e_psk_encryptedB\x10\n\x0e_key_encryptedB\x13\n\x11_dhchap_ctrlr_keyB\x16\n\x14_ctrlr_key_encrypted\"\x9a\x01\n\x13\x63hange_host_key_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x17\n\ndhchap_key\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x1d\n\x10\x64hchap_ctrlr_key\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\r\n\x0b_dhchap_keyB\x13\n\x11_dhchap_ctrlr_key\"i\n get_connection_io_statistics_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x12\n\x05reset\x18\x03 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_reset\"X\n\x0fremove_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x12\n\x05\x66orce\x18\x03 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_force\"O\n\x0elist_hosts_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x19\n\x0c\x63lear_alerts\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_clear_alerts\"U\n\x14list_connections_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x19\n\x0c\x63lear_alerts\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_clear_alerts\"\x89\x02\n\x13\x63reate_listener_req\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\thost_name\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12#\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamilyH\x00\x88\x01\x01\x12\x14\n\x07trsvcid\x18\x06 \x01(\rH\x01\x88\x01\x01\x12\x13\n\x06secure\x18\x07 \x01(\x08H\x02\x88\x01\x01\x12\x1d\n\x10verify_host_name\x18\x08 \x01(\x08H\x03\x88\x01\x01\x12\x12\n\x05\x66orce\x18\t \x01(\x08H\x04\x88\x01\x01\x42\t\n\x07_adrfamB\n\n\x08_trsvcidB\t\n\x07_secureB\x13\n\x11_verify_host_nameB\x08\n\x06_force\"\xb5\x01\n\x13\x64\x65lete_listener_req\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\thost_name\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12#\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamilyH\x00\x88\x01\x01\x12\x14\n\x07trsvcid\x18\x06 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05\x66orce\x18\x07 \x01(\x08H\x02\x88\x01\x01\x42\t\n\x07_adrfamB\n\n\x08_trsvcidB\x08\n\x06_force\"\'\n\x12list_listeners_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\"q\n\x13list_subsystems_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1a\n\rserial_number\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x10\n\x0e_serial_number\"\x14\n\x12get_subsystems_req\"U\n%get_spdk_nvmf_log_flags_and_level_req\x12\x1a\n\rall_log_flags\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\x10\n\x0e_all_log_flags\"5\n\x1a\x64isable_spdk_nvmf_logs_req\x12\x17\n\x0f\x65xtra_log_flags\x18\x01 \x03(\t\"\x97\x01\n\x16set_spdk_nvmf_logs_req\x12!\n\tlog_level\x18\x01 \x01(\x0e\x32\t.LogLevelH\x00\x88\x01\x01\x12#\n\x0bprint_level\x18\x02 \x01(\x0e\x32\t.LogLevelH\x01\x88\x01\x01\x12\x17\n\x0f\x65xtra_log_flags\x18\x03 \x03(\tB\x0c\n\n_log_levelB\x0e\n\x0c_print_level\"@\n\x14get_gateway_info_req\x12\x18\n\x0b\x63li_version\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_cli_version\"\x1b\n\x19get_gateway_log_level_req\";\n\x19set_gateway_log_level_req\x12\x1e\n\tlog_level\x18\x01 \x01(\x0e\x32\x0b.GwLogLevel\"8\n\x1fshow_gateway_listeners_info_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\"\x17\n\x15get_gateway_stats_req\"\x16\n\x14get_thread_stats_req\"0\n\x1dset_gateway_io_stats_mode_req\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\"<\n\x0f\x61na_group_state\x12\x0e\n\x06grp_id\x18\x01 \x01(\r\x12\x19\n\x05state\x18\x02 \x01(\x0e\x32\n.ana_state\"?\n\x0enqn_ana_states\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12 \n\x06states\x18\x02 \x03(\x0b\x32\x10.ana_group_state\"+\n\x08\x61na_info\x12\x1f\n\x06states\x18\x01 \x03(\x0b\x32\x0f.nqn_ana_states\"3\n\nreq_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\"C\n\rsubsys_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0b\n\x03nqn\x18\x03 \x01(\t\"B\n\x0bnsid_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0c\n\x04nsid\x18\x03 \x01(\r\"1\n\x0fsubsystems_info\x12\x1e\n\nsubsystems\x18\x01 \x03(\x0b\x32\n.subsystem\"\xc2\x03\n\tsubsystem\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x0f\n\x07subtype\x18\x02 \x01(\t\x12)\n\x10listen_addresses\x18\x03 \x03(\x0b\x32\x0f.listen_address\x12\x14\n\x05hosts\x18\x04 \x03(\x0b\x32\x05.host\x12\x16\n\x0e\x61llow_any_host\x18\x05 \x01(\x08\x12\x1a\n\rserial_number\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0cmodel_number\x18\x07 \x01(\tH\x01\x88\x01\x01\x12\x1b\n\x0emax_namespaces\x18\x08 \x01(\rH\x02\x88\x01\x01\x12\x17\n\nmin_cntlid\x18\t \x01(\rH\x03\x88\x01\x01\x12\x17\n\nmax_cntlid\x18\n \x01(\rH\x04\x88\x01\x01\x12\x1e\n\nnamespaces\x18\x0b \x03(\x0b\x32\n.namespace\x12\x1b\n\x0ehas_dhchap_key\x18\x0c \x01(\x08H\x05\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\r \x03(\tB\x10\n\x0e_serial_numberB\x0f\n\r_model_numberB\x11\n\x0f_max_namespacesB\r\n\x0b_min_cntlidB\r\n\x0b_max_cntlidB\x11\n\x0f_has_dhchap_key\"\x97\x01\n\x0elisten_address\x12\x0e\n\x06trtype\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x64rfam\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12\x0f\n\x07trsvcid\x18\x04 \x01(\t\x12\x16\n\ttransport\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06secure\x18\x06 \x01(\x08H\x01\x88\x01\x01\x42\x0c\n\n_transportB\t\n\x07_secure\"\x84\x02\n\tnamespace\x12\x0c\n\x04nsid\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\tbdev_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05nguid\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04uuid\x18\x05 \x01(\tH\x02\x88\x01\x01\x12\x15\n\x08\x61nagrpid\x18\x06 \x01(\rH\x03\x88\x01\x01\x12\x12\n\x05nonce\x18\x07 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0c\x61uto_visible\x18\x08 \x01(\x08H\x05\x88\x01\x01\x12\r\n\x05hosts\x18\t \x03(\tB\x0c\n\n_bdev_nameB\x08\n\x06_nguidB\x07\n\x05_uuidB\x0b\n\t_anagrpidB\x08\n\x06_nonceB\x0f\n\r_auto_visible\"`\n\x13subsystems_info_cli\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\"\n\nsubsystems\x18\x03 \x03(\x0b\x32\x0e.subsystem_cli\"\xf6\x02\n\rsubsystem_cli\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\tenable_ha\x18\x02 \x01(\x08\x12\x15\n\rserial_number\x18\x03 \x01(\t\x12\x14\n\x0cmodel_number\x18\x04 \x01(\t\x12\x12\n\nmin_cntlid\x18\x05 \x01(\r\x12\x12\n\nmax_cntlid\x18\x06 \x01(\r\x12\x17\n\x0fnamespace_count\x18\x07 \x01(\r\x12\x0f\n\x07subtype\x18\x08 \x01(\t\x12\x16\n\x0emax_namespaces\x18\t \x01(\r\x12\x1b\n\x0ehas_dhchap_key\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x1b\n\x0e\x61llow_any_host\x18\x0b \x01(\x08H\x01\x88\x01\x01\x12 \n\x13\x63reated_without_key\x18\x0c \x01(\x08H\x02\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\r \x03(\tB\x11\n\x0f_has_dhchap_keyB\x11\n\x0f_allow_any_hostB\x16\n\x14_created_without_key\"\xbb\x05\n\x0cgateway_info\x12\x13\n\x0b\x63li_version\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\r\n\x05group\x18\x04 \x01(\t\x12\x0c\n\x04\x61\x64\x64r\x18\x05 \x01(\t\x12\x0c\n\x04port\x18\x06 \x01(\t\x12\x13\n\x0b\x62ool_status\x18\x07 \x01(\x08\x12\x0e\n\x06status\x18\x08 \x01(\x05\x12\x15\n\rerror_message\x18\t \x01(\t\x12\x19\n\x0cspdk_version\x18\n \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x14load_balancing_group\x18\x0b \x01(\r\x12\x10\n\x08hostname\x18\x0c \x01(\t\x12\x1b\n\x0emax_subsystems\x18\r \x01(\rH\x01\x88\x01\x01\x12\x1b\n\x0emax_namespaces\x18\x0e \x01(\rH\x02\x88\x01\x01\x12$\n\x17max_hosts_per_subsystem\x18\x0f \x01(\rH\x03\x88\x01\x01\x12)\n\x1cmax_namespaces_per_subsystem\x18\x10 \x01(\rH\x04\x88\x01\x01\x12\x16\n\tmax_hosts\x18\x11 \x01(\rH\x05\x88\x01\x01\x12(\n\x1bgateway_initialization_over\x18\x12 \x01(\x08H\x06\x88\x01\x01\x12\x1d\n\x10io_stats_enabled\x18\x13 \x01(\x08H\x07\x88\x01\x01\x12\x15\n\x08location\x18\x14 \x01(\tH\x08\x88\x01\x01\x42\x0f\n\r_spdk_versionB\x11\n\x0f_max_subsystemsB\x11\n\x0f_max_namespacesB\x1a\n\x18_max_hosts_per_subsystemB\x1f\n\x1d_max_namespaces_per_subsystemB\x0c\n\n_max_hostsB\x1e\n\x1c_gateway_initialization_overB\x13\n\x11_io_stats_enabledB\x0b\n\t_location\"E\n\x0b\x63li_version\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\"D\n\ngw_version\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\"+\n\x19poll_group_transport_info\x12\x0e\n\x06trtype\x18\x01 \x01(\t\"\xe5\x01\n\x0fpoll_group_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x61\x64min_qpairs\x18\x02 \x01(\r\x12\x11\n\tio_qpairs\x18\x03 \x01(\r\x12\x1c\n\x14\x63urrent_admin_qpairs\x18\x04 \x01(\r\x12\x19\n\x11\x63urrent_io_qpairs\x18\x05 \x01(\r\x12\x17\n\x0fpending_bdev_io\x18\x06 \x01(\x04\x12\x19\n\x11\x63ompleted_nvme_io\x18\x07 \x01(\x04\x12.\n\ntransports\x18\x08 \x03(\x0b\x32\x1a.poll_group_transport_info\"u\n\x12gateway_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x11\n\ttick_rate\x18\x03 \x01(\x04\x12%\n\x0bpoll_groups\x18\x04 \x03(\x0b\x32\x10.poll_group_info\"q\n\x11thread_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\"\n\x07threads\x18\x03 \x03(\x0b\x32\x11.spdk_thread_info\x12\x11\n\ttick_rate\x18\x04 \x01(\x04\"<\n\x10spdk_thread_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x62usy\x18\x02 \x01(\x04\x12\x0c\n\x04idle\x18\x03 \x01(\x04\"\xd3\x01\n\rlistener_info\x12\x11\n\thost_name\x18\x01 \x01(\t\x12\x0e\n\x06trtype\x18\x02 \x01(\t\x12\x1e\n\x06\x61\x64rfam\x18\x03 \x01(\x0e\x32\x0e.AddressFamily\x12\x0e\n\x06traddr\x18\x04 \x01(\t\x12\x0f\n\x07trsvcid\x18\x05 \x01(\r\x12\x13\n\x06secure\x18\x06 \x01(\x08H\x00\x88\x01\x01\x12\x13\n\x06\x61\x63tive\x18\x07 \x01(\x08H\x01\x88\x01\x01\x12\x13\n\x06manual\x18\x08 \x01(\x08H\x02\x88\x01\x01\x42\t\n\x07_secureB\t\n\x07_activeB\t\n\x07_manual\"Z\n\x0elisteners_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12!\n\tlisteners\x18\x03 \x03(\x0b\x32\x0e.listener_info\"^\n\x15gateway_listener_info\x12 \n\x08listener\x18\x01 \x01(\x0b\x32\x0e.listener_info\x12#\n\tlb_states\x18\x02 \x03(\x0b\x32\x10.ana_group_state\"m\n\x16gateway_listeners_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12,\n\x0cgw_listeners\x18\x03 \x03(\x0b\x32\x16.gateway_listener_info\"\x9b\x02\n\x04host\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x14\n\x07use_psk\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x17\n\nuse_dhchap\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12\x32\n%disconnected_due_to_keepalive_timeout\x18\x04 \x01(\x08H\x02\x88\x01\x01\x12\x41\n\x18\x64hchap_controller_origin\x18\x05 \x01(\x0e\x32\x1a.DHCHAPControllerKeyOriginH\x03\x88\x01\x01\x42\n\n\x08_use_pskB\r\n\x0b_use_dhchapB(\n&_disconnected_due_to_keepalive_timeoutB\x1b\n\x19_dhchap_controller_origin\"7\n\rlatency_stats\x12\x0b\n\x03min\x18\x01 \x01(\x04\x12\x0b\n\x03max\x18\x02 \x01(\x04\x12\x0c\n\x04mean\x18\x03 \x01(\x04\"\x98\x01\n\rlatency_group\x12\x10\n\x08io_count\x18\x01 \x01(\x04\x12\x1d\n\x05total\x18\x02 \x01(\x0b\x32\x0e.latency_stats\x12\x1c\n\x04\x62\x64\x65v\x18\x03 \x01(\x0b\x32\x0e.latency_stats\x12\x1b\n\x03net\x18\x04 \x01(\x0b\x32\x0e.latency_stats\x12\x1b\n\x03qos\x18\x05 \x01(\x0b\x32\x0e.latency_stats\"X\n\x0b\x62ucket_info\x12\x0c\n\x04size\x18\x01 \x01(\r\x12\x1c\n\x04read\x18\x02 \x01(\x0b\x32\x0e.latency_group\x12\x1d\n\x05write\x18\x03 \x01(\x0b\x32\x0e.latency_group\"\xb7\x01\n\x18\x63onnection_io_statistics\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x10\n\x08host_nqn\x18\x04 \x01(\t\x12\x1a\n\rtotal_num_ios\x18\x05 \x01(\x04H\x00\x88\x01\x01\x12\x1d\n\x07\x62uckets\x18\x06 \x03(\x0b\x32\x0c.bucket_infoB\x10\n\x0e_total_num_ios\"x\n\nhosts_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x16\n\x0e\x61llow_any_host\x18\x03 \x01(\x08\x12\x15\n\rsubsystem_nqn\x18\x04 \x01(\t\x12\x14\n\x05hosts\x18\x05 \x03(\x0b\x32\x05.host\"\xf8\x03\n\nconnection\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x0e\n\x06traddr\x18\x02 \x01(\t\x12\x0f\n\x07trsvcid\x18\x03 \x01(\r\x12\x0e\n\x06trtype\x18\x04 \x01(\t\x12\x1e\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamily\x12\x11\n\tconnected\x18\x06 \x01(\x08\x12\x14\n\x0cqpairs_count\x18\x07 \x01(\x05\x12\x15\n\rcontroller_id\x18\x08 \x01(\x05\x12\x13\n\x06secure\x18\t \x01(\x08H\x00\x88\x01\x01\x12\x14\n\x07use_psk\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x17\n\nuse_dhchap\x18\x0b \x01(\x08H\x02\x88\x01\x01\x12\x16\n\tsubsystem\x18\x0c \x01(\tH\x03\x88\x01\x01\x12\x32\n%disconnected_due_to_keepalive_timeout\x18\r \x01(\x08H\x04\x88\x01\x01\x12\x41\n\x18\x64hchap_controller_origin\x18\x0e \x01(\x0e\x32\x1a.DHCHAPControllerKeyOriginH\x05\x88\x01\x01\x42\t\n\x07_secureB\n\n\x08_use_pskB\r\n\x0b_use_dhchapB\x0c\n\n_subsystemB(\n&_disconnected_due_to_keepalive_timeoutB\x1b\n\x19_dhchap_controller_origin\"r\n\x10\x63onnections_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12 \n\x0b\x63onnections\x18\x04 \x03(\x0b\x32\x0b.connection\"\xd8\x07\n\rnamespace_cli\x12\x0c\n\x04nsid\x18\x01 \x01(\r\x12\x11\n\tbdev_name\x18\x02 \x01(\t\x12\x16\n\x0erbd_image_name\x18\x03 \x01(\t\x12\x15\n\rrbd_pool_name\x18\x04 \x01(\t\x12\x1c\n\x14load_balancing_group\x18\x05 \x01(\r\x12\x12\n\nblock_size\x18\x06 \x01(\r\x12\x16\n\x0erbd_image_size\x18\x07 \x01(\x04\x12\x0c\n\x04uuid\x18\x08 \x01(\t\x12\x19\n\x11rw_ios_per_second\x18\t \x01(\x04\x12\x1c\n\x14rw_mbytes_per_second\x18\n \x01(\x04\x12\x1b\n\x13r_mbytes_per_second\x18\x0b \x01(\x04\x12\x1b\n\x13w_mbytes_per_second\x18\x0c \x01(\x04\x12\x14\n\x0c\x61uto_visible\x18\r \x01(\x08\x12\r\n\x05hosts\x18\x0e \x03(\t\x12\x1d\n\x10ns_subsystem_nqn\x18\x0f \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0btrash_image\x18\x10 \x01(\x08H\x01\x88\x01\x01\x12 \n\x13\x64isable_auto_resize\x18\x11 \x01(\x08H\x02\x88\x01\x01\x12\x16\n\tread_only\x18\x12 \x01(\x08H\x03\x88\x01\x01\x12\x19\n\x0c\x63luster_name\x18\x13 \x01(\tH\x04\x88\x01\x01\x12,\n\x1f\x63onfigured_load_balancing_group\x18\x14 \x01(\rH\x05\x88\x01\x01\x12\x1d\n\x10image_was_shrunk\x18\x15 \x01(\x08H\x06\x88\x01\x01\x12\x1f\n\x12rbd_data_pool_name\x18\x16 \x01(\tH\x07\x88\x01\x01\x12\x15\n\x08location\x18\x17 \x01(\tH\x08\x88\x01\x01\x12!\n\x14rados_namespace_name\x18\x18 \x01(\tH\t\x88\x01\x01\x12\x37\n\x14\x65ncryption_algorithm\x18\x19 \x01(\x0e\x32\x14.EncryptionAlgorithmH\n\x88\x01\x01\x12-\n\x12\x65ncryption_entries\x18\x1a \x03(\x0b\x32\x11.encryption_entryB\x13\n\x11_ns_subsystem_nqnB\x0e\n\x0c_trash_imageB\x16\n\x14_disable_auto_resizeB\x0c\n\n_read_onlyB\x0f\n\r_cluster_nameB\"\n _configured_load_balancing_groupB\x13\n\x11_image_was_shrunkB\x15\n\x13_rbd_data_pool_nameB\x0b\n\t_locationB\x17\n\x15_rados_namespace_nameB\x17\n\x15_encryption_algorithm\"s\n\x0fnamespaces_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\"\n\nnamespaces\x18\x04 \x03(\x0b\x32\x0e.namespace_cli\"1\n\x12namespace_io_error\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\r\"\x91\x01\n\x1dlist_namespaces_io_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x11\n\ttick_rate\x18\x03 \x01(\x04\x12\r\n\x05ticks\x18\x04 \x01(\x04\x12\'\n\nnamespaces\x18\x05 \x03(\x0b\x32\x13.bdev_io_stats_info\"\xf7\x04\n\x12\x62\x64\x65v_io_stats_info\x12\x11\n\tbdev_name\x18\x01 \x01(\t\x12\x12\n\nbytes_read\x18\x02 \x01(\x04\x12\x14\n\x0cnum_read_ops\x18\x03 \x01(\x04\x12\x15\n\rbytes_written\x18\x04 \x01(\x04\x12\x15\n\rnum_write_ops\x18\x05 \x01(\x04\x12\x16\n\x0e\x62ytes_unmapped\x18\x06 \x01(\x04\x12\x15\n\rnum_unmap_ops\x18\x07 \x01(\x04\x12\x14\n\x0c\x62ytes_copied\x18\x08 \x01(\x04\x12\x14\n\x0cnum_copy_ops\x18\t \x01(\x04\x12\x1a\n\x12read_latency_ticks\x18\n \x01(\x04\x12\x1e\n\x16max_read_latency_ticks\x18\x0b \x01(\x04\x12\x1e\n\x16min_read_latency_ticks\x18\x0c \x01(\x04\x12\x1b\n\x13write_latency_ticks\x18\r \x01(\x04\x12\x1f\n\x17max_write_latency_ticks\x18\x0e \x01(\x04\x12\x1f\n\x17min_write_latency_ticks\x18\x0f \x01(\x04\x12\x1b\n\x13unmap_latency_ticks\x18\x10 \x01(\x04\x12\x1f\n\x17max_unmap_latency_ticks\x18\x11 \x01(\x04\x12\x1f\n\x17min_unmap_latency_ticks\x18\x12 \x01(\x04\x12\x1a\n\x12\x63opy_latency_ticks\x18\x13 \x01(\x04\x12\x1e\n\x16max_copy_latency_ticks\x18\x14 \x01(\x04\x12\x1e\n\x16min_copy_latency_ticks\x18\x15 \x01(\x04\x12%\n\x08io_error\x18\x16 \x03(\x0b\x32\x13.namespace_io_error\"\xda\x05\n\x17namespace_io_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x0c\n\x04nsid\x18\x04 \x01(\r\x12\x11\n\x04uuid\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x11\n\tbdev_name\x18\x06 \x01(\t\x12\x11\n\ttick_rate\x18\x07 \x01(\x04\x12\r\n\x05ticks\x18\x08 \x01(\x04\x12\x12\n\nbytes_read\x18\t \x01(\x04\x12\x14\n\x0cnum_read_ops\x18\n \x01(\x04\x12\x15\n\rbytes_written\x18\x0b \x01(\x04\x12\x15\n\rnum_write_ops\x18\x0c \x01(\x04\x12\x16\n\x0e\x62ytes_unmapped\x18\r \x01(\x04\x12\x15\n\rnum_unmap_ops\x18\x0e \x01(\x04\x12\x1a\n\x12read_latency_ticks\x18\x0f \x01(\x04\x12\x1e\n\x16max_read_latency_ticks\x18\x10 \x01(\x04\x12\x1e\n\x16min_read_latency_ticks\x18\x11 \x01(\x04\x12\x1b\n\x13write_latency_ticks\x18\x12 \x01(\x04\x12\x1f\n\x17max_write_latency_ticks\x18\x13 \x01(\x04\x12\x1f\n\x17min_write_latency_ticks\x18\x14 \x01(\x04\x12\x1b\n\x13unmap_latency_ticks\x18\x15 \x01(\x04\x12\x1f\n\x17max_unmap_latency_ticks\x18\x16 \x01(\x04\x12\x1f\n\x17min_unmap_latency_ticks\x18\x17 \x01(\x04\x12\x1a\n\x12\x63opy_latency_ticks\x18\x18 \x01(\x04\x12\x1e\n\x16max_copy_latency_ticks\x18\x19 \x01(\x04\x12\x1e\n\x16min_copy_latency_ticks\x18\x1a \x01(\x04\x12%\n\x08io_error\x18\x1b \x03(\x0b\x32\x13.namespace_io_errorB\x07\n\x05_uuid\"3\n\x12spdk_log_flag_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08\"\xba\x01\n\"spdk_nvmf_log_flags_and_level_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12+\n\x0envmf_log_flags\x18\x03 \x03(\x0b\x32\x13.spdk_log_flag_info\x12\x1c\n\tlog_level\x18\x04 \x01(\x0e\x32\t.LogLevel\x12\"\n\x0flog_print_level\x18\x05 \x01(\x0e\x32\t.LogLevel\"_\n\x16gateway_log_level_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x1e\n\tlog_level\x18\x03 \x01(\x0e\x32\x0b.GwLogLevel\"E\n\x10\x65ncryption_entry\x12!\n\x06\x66ormat\x18\x01 \x01(\x0e\x32\x11.EncryptionFormat\x12\x0e\n\x06key_id\x18\x02 \x01(\t*#\n\rAddressFamily\x12\x08\n\x04ipv4\x10\x00\x12\x08\n\x04ipv6\x10\x01*C\n\x08LogLevel\x12\t\n\x05\x45RROR\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\n\n\x06NOTICE\x10\x02\x12\x08\n\x04INFO\x10\x03\x12\t\n\x05\x44\x45\x42UG\x10\x04*S\n\nGwLogLevel\x12\n\n\x06notset\x10\x00\x12\t\n\x05\x64\x65\x62ug\x10\n\x12\x08\n\x04info\x10\x14\x12\x0b\n\x07warning\x10\x1e\x12\t\n\x05\x65rror\x10(\x12\x0c\n\x08\x63ritical\x10\x32*2\n\x10\x45ncryptionFormat\x12\x08\n\x04none\x10\x00\x12\t\n\x05luks1\x10\x01\x12\t\n\x05luks2\x10\x02*?\n\x13\x45ncryptionAlgorithm\x12\x10\n\x0cno_algorithm\x10\x00\x12\n\n\x06\x61\x65s128\x10\x01\x12\n\n\x06\x61\x65s256\x10\x02*R\n\x19\x44HCHAPControllerKeyOrigin\x12\n\n\x06no_key\x10\x00\x12\x11\n\rhost_specific\x10\x01\x12\x16\n\x12subsystem_implicit\x10\x02*J\n\tana_state\x12\t\n\x05UNSET\x10\x00\x12\r\n\tOPTIMIZED\x10\x01\x12\x11\n\rNON_OPTIMIZED\x10\x02\x12\x10\n\x0cINACCESSIBLE\x10\x03\x32\xe1\x17\n\x07Gateway\x12\x33\n\rnamespace_add\x12\x12.namespace_add_req\x1a\x0c.nsid_status\"\x00\x12;\n\x10\x63reate_subsystem\x12\x15.create_subsystem_req\x1a\x0e.subsys_status\"\x00\x12\x38\n\x10\x64\x65lete_subsystem\x12\x15.delete_subsystem_req\x1a\x0b.req_status\"\x00\x12@\n\x14\x63hange_subsystem_key\x12\x19.change_subsystem_key_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15\x61\x64\x64_subsystem_network\x12\x1a.add_subsystem_network_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15\x64\x65l_subsystem_network\x12\x1a.del_subsystem_network_req\x1a\x0b.req_status\"\x00\x12J\n\x19\x61\x64\x64_kmip_server_endpoints\x12\x1e.add_kmip_server_endpoints_req\x1a\x0b.req_status\"\x00\x12J\n\x19\x64\x65l_kmip_server_endpoints\x12\x1e.del_kmip_server_endpoints_req\x1a\x0b.req_status\"\x00\x12\\\n\x1alist_kmip_server_endpoints\x12\x1f.list_kmip_server_endpoints_req\x1a\x1b.kmip_server_endpoints_info\"\x00\x12;\n\x0flist_namespaces\x12\x14.list_namespaces_req\x1a\x10.namespaces_info\"\x00\x12\x38\n\x10namespace_resize\x12\x15.namespace_resize_req\x1a\x0b.req_status\"\x00\x12Q\n\x16namespace_get_io_stats\x12\x1b.namespace_get_io_stats_req\x1a\x18.namespace_io_stats_info\"\x00\x12[\n\x18list_namespaces_io_stats\x12\x1d.list_namespaces_io_stats_req\x1a\x1e.list_namespaces_io_stats_info\"\x00\x12\x41\n\x18namespace_set_qos_limits\x12\x16.namespace_set_qos_req\x1a\x0b.req_status\"\x00\x12\x62\n%namespace_change_load_balancing_group\x12*.namespace_change_load_balancing_group_req\x1a\x0b.req_status\"\x00\x12N\n\x1bnamespace_change_visibility\x12 .namespace_change_visibility_req\x1a\x0b.req_status\"\x00\x12J\n\x19namespace_change_location\x12\x1e.namespace_change_location_req\x1a\x0b.req_status\"\x00\x12R\n\x1dnamespace_set_rbd_trash_image\x12\".namespace_set_rbd_trash_image_req\x1a\x0b.req_status\"\x00\x12J\n\x19namespace_set_auto_resize\x12\x1e.namespace_set_auto_resize_req\x1a\x0b.req_status\"\x00\x12\x38\n\x10namespace_delete\x12\x15.namespace_delete_req\x1a\x0b.req_status\"\x00\x12<\n\x12namespace_add_host\x12\x17.namespace_add_host_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15namespace_delete_host\x12\x1a.namespace_delete_host_req\x1a\x0b.req_status\"\x00\x12(\n\x08\x61\x64\x64_host\x12\r.add_host_req\x1a\x0b.req_status\"\x00\x12.\n\x0bremove_host\x12\x10.remove_host_req\x1a\x0b.req_status\"\x00\x12\x36\n\x0f\x63hange_host_key\x12\x14.change_host_key_req\x1a\x0b.req_status\"\x00\x12,\n\nlist_hosts\x12\x0f.list_hosts_req\x1a\x0b.hosts_info\"\x00\x12>\n\x10list_connections\x12\x15.list_connections_req\x1a\x11.connections_info\"\x00\x12^\n\x1cget_connection_io_statistics\x12!.get_connection_io_statistics_req\x1a\x19.connection_io_statistics\"\x00\x12\x36\n\x0f\x63reate_listener\x12\x14.create_listener_req\x1a\x0b.req_status\"\x00\x12\x36\n\x0f\x64\x65lete_listener\x12\x14.delete_listener_req\x1a\x0b.req_status\"\x00\x12\x38\n\x0elist_listeners\x12\x13.list_listeners_req\x1a\x0f.listeners_info\"\x00\x12?\n\x0flist_subsystems\x12\x14.list_subsystems_req\x1a\x14.subsystems_info_cli\"\x00\x12\x39\n\x0eget_subsystems\x12\x13.get_subsystems_req\x1a\x10.subsystems_info\"\x00\x12)\n\rset_ana_state\x12\t.ana_info\x1a\x0b.req_status\"\x00\x12r\n!get_spdk_nvmf_log_flags_and_level\x12&.get_spdk_nvmf_log_flags_and_level_req\x1a#.spdk_nvmf_log_flags_and_level_info\"\x00\x12\x44\n\x16\x64isable_spdk_nvmf_logs\x12\x1b.disable_spdk_nvmf_logs_req\x1a\x0b.req_status\"\x00\x12<\n\x12set_spdk_nvmf_logs\x12\x17.set_spdk_nvmf_logs_req\x1a\x0b.req_status\"\x00\x12:\n\x10get_gateway_info\x12\x15.get_gateway_info_req\x1a\r.gateway_info\"\x00\x12N\n\x15get_gateway_log_level\x12\x1a.get_gateway_log_level_req\x1a\x17.gateway_log_level_info\"\x00\x12\x42\n\x15set_gateway_log_level\x12\x1a.set_gateway_log_level_req\x1a\x0b.req_status\"\x00\x12Z\n\x1bshow_gateway_listeners_info\x12 .show_gateway_listeners_info_req\x1a\x17.gateway_listeners_info\"\x00\x12\x42\n\x11get_gateway_stats\x12\x16.get_gateway_stats_req\x1a\x13.gateway_stats_info\"\x00\x12?\n\x10get_thread_stats\x12\x15.get_thread_stats_req\x1a\x12.thread_stats_info\"\x00\x12J\n\x19set_gateway_io_stats_mode\x12\x1e.set_gateway_io_stats_mode_req\x1a\x0b.req_status\"\x00\x42+Z)github.com/ceph/ceph-nvmeof/lib/go/nvmeofb\x06proto3' + serialized_pb=b'\n&dashboard/services/proto/gateway.proto\"\x84\x06\n\x11namespace_add_req\x12\x15\n\rrbd_pool_name\x18\x01 \x01(\t\x12\x16\n\x0erbd_image_name\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x11\n\x04nsid\x18\x04 \x01(\rH\x00\x88\x01\x01\x12\x12\n\nblock_size\x18\x05 \x01(\r\x12\x11\n\x04uuid\x18\x06 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x61nagrpid\x18\x07 \x01(\x05H\x02\x88\x01\x01\x12\x19\n\x0c\x63reate_image\x18\x08 \x01(\x08H\x03\x88\x01\x01\x12\x11\n\x04size\x18\t \x01(\x04H\x04\x88\x01\x01\x12\x12\n\x05\x66orce\x18\n \x01(\x08H\x05\x88\x01\x01\x12\x1c\n\x0fno_auto_visible\x18\x0b \x01(\x08H\x06\x88\x01\x01\x12\x18\n\x0btrash_image\x18\x0c \x01(\x08H\x07\x88\x01\x01\x12 \n\x13\x64isable_auto_resize\x18\r \x01(\x08H\x08\x88\x01\x01\x12\x16\n\tread_only\x18\x0e \x01(\x08H\t\x88\x01\x01\x12\x1f\n\x12rbd_data_pool_name\x18\x0f \x01(\tH\n\x88\x01\x01\x12\x15\n\x08location\x18\x10 \x01(\tH\x0b\x88\x01\x01\x12!\n\x14rados_namespace_name\x18\x11 \x01(\tH\x0c\x88\x01\x01\x12-\n\x12\x65ncryption_entries\x18\x12 \x03(\x0b\x32\x11.encryption_entry\x12\x37\n\x14\x65ncryption_algorithm\x18\x13 \x01(\x0e\x32\x14.EncryptionAlgorithmH\r\x88\x01\x01\x42\x07\n\x05_nsidB\x07\n\x05_uuidB\x0b\n\t_anagrpidB\x0f\n\r_create_imageB\x07\n\x05_sizeB\x08\n\x06_forceB\x12\n\x10_no_auto_visibleB\x0e\n\x0c_trash_imageB\x16\n\x14_disable_auto_resizeB\x0c\n\n_read_onlyB\x15\n\x13_rbd_data_pool_nameB\x0b\n\t_locationB\x17\n\x15_rados_namespace_nameB\x17\n\x15_encryption_algorithm\"{\n\x14namespace_resize_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08new_size\x18\x04 \x01(\x04\x42\x10\n\x0e_OBSOLETE_uuid\"o\n\x1anamespace_get_io_stats_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuid\"h\n\x1clist_namespaces_io_stats_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04nsid\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x07\n\x05_nsid\"\xee\x02\n\x15namespace_set_qos_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x1e\n\x11rw_ios_per_second\x18\x04 \x01(\x04H\x01\x88\x01\x01\x12!\n\x14rw_mbytes_per_second\x18\x05 \x01(\x04H\x02\x88\x01\x01\x12 \n\x13r_mbytes_per_second\x18\x06 \x01(\x04H\x03\x88\x01\x01\x12 \n\x13w_mbytes_per_second\x18\x07 \x01(\x04H\x04\x88\x01\x01\x12\x12\n\x05\x66orce\x18\x08 \x01(\x08H\x05\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x14\n\x12_rw_ios_per_secondB\x17\n\x15_rw_mbytes_per_secondB\x16\n\x14_r_mbytes_per_secondB\x16\n\x14_w_mbytes_per_secondB\x08\n\x06_force\"\xbe\x01\n)namespace_change_load_balancing_group_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08\x61nagrpid\x18\x04 \x01(\x05\x12\x1a\n\rauto_lb_logic\x18\x05 \x01(\x08H\x01\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x10\n\x0e_auto_lb_logic\"z\n\x1fnamespace_change_visibility_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x14\n\x0c\x61uto_visible\x18\x03 \x01(\x08\x12\x12\n\x05\x66orce\x18\x04 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_force\"h\n\x1dnamespace_change_location_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x15\n\x08location\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_location\"]\n!namespace_set_rbd_trash_image_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x13\n\x0btrash_image\x18\x03 \x01(\x08\"Y\n\x1dnamespace_set_auto_resize_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x13\n\x0b\x61uto_resize\x18\x03 \x01(\x08\"\x8f\x01\n\x14namespace_delete_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x1a\n\rOBSOLETE_uuid\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x16\n\ti_am_sure\x18\x04 \x01(\x08H\x01\x88\x01\x01\x42\x10\n\x0e_OBSOLETE_uuidB\x0c\n\n_i_am_sure\"m\n\x16namespace_add_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x10\n\x08host_nqn\x18\x03 \x01(\t\x12\x12\n\x05\x66orce\x18\x04 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_force\"R\n\x19namespace_delete_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x0c\n\x04nsid\x18\x02 \x01(\r\x12\x10\n\x08host_nqn\x18\x03 \x01(\t\"\xf5\x02\n\x14\x63reate_subsystem_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x15\n\rserial_number\x18\x02 \x01(\t\x12\x1b\n\x0emax_namespaces\x18\x03 \x01(\rH\x00\x88\x01\x01\x12\x11\n\tenable_ha\x18\x04 \x01(\x08\x12\x1c\n\x0fno_group_append\x18\x05 \x01(\x08H\x01\x88\x01\x01\x12\x17\n\ndhchap_key\x18\x06 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\rkey_encrypted\x18\x07 \x01(\x08H\x03\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\x08 \x03(\t\x12\x1d\n\x10secure_listeners\x18\t \x01(\x08H\x04\x88\x01\x01\x12\x11\n\x04port\x18\n \x01(\rH\x05\x88\x01\x01\x42\x11\n\x0f_max_namespacesB\x12\n\x10_no_group_appendB\r\n\x0b_dhchap_keyB\x10\n\x0e_key_encryptedB\x13\n\x11_secure_listenersB\x07\n\x05_port\"q\n\x14\x64\x65lete_subsystem_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x12\n\x05\x66orce\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x16\n\ti_am_sure\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x08\n\x06_forceB\x0c\n\n_i_am_sure\"Y\n\x18\x63hange_subsystem_key_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x17\n\ndhchap_key\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_dhchap_key\"H\n\x19\x61\x64\x64_subsystem_network_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x14\n\x0cnetwork_mask\x18\x02 \x01(\t\"H\n\x19\x64\x65l_subsystem_network_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x14\n\x0cnetwork_mask\x18\x02 \x01(\t\"/\n\x16gw_refresh_network_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\"b\n\x19gw_refresh_network_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\r\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\t\x12\x0f\n\x07removed\x18\x04 \x03(\t\"C\n\x14kmip_server_endpoint\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\x04port\x18\x02 \x01(\rH\x00\x88\x01\x01\x42\x07\n\x05_port\"e\n\x18kmip_server_endpoint_cli\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12\x0f\n\x07\x61\x64\x64ress\x18\x03 \x01(\t\x12\x0c\n\x04port\x18\x04 \x01(\r\"u\n\x1d\x61\x64\x64_kmip_server_endpoints_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12(\n\tendpoints\x18\x03 \x03(\x0b\x32\x15.kmip_server_endpoint\"u\n\x1d\x64\x65l_kmip_server_endpoints_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x13\n\x0bserver_name\x18\x02 \x01(\t\x12(\n\tendpoints\x18\x03 \x03(\x0b\x32\x15.kmip_server_endpoint\"x\n\x1elist_kmip_server_endpoints_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bserver_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x0e\n\x0c_server_name\"q\n\x1akmip_server_endpoints_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12,\n\tendpoints\x18\x03 \x03(\x0b\x32\x19.kmip_server_endpoint_cli\"`\n\x13list_namespaces_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x11\n\x04nsid\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x11\n\x04uuid\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nsidB\x07\n\x05_uuid\"\xc3\x02\n\x0c\x61\x64\x64_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x10\n\x03psk\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ndhchap_key\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x1a\n\rpsk_encrypted\x18\x05 \x01(\x08H\x02\x88\x01\x01\x12\x1a\n\rkey_encrypted\x18\x06 \x01(\x08H\x03\x88\x01\x01\x12\x1d\n\x10\x64hchap_ctrlr_key\x18\x07 \x01(\tH\x04\x88\x01\x01\x12 \n\x13\x63trlr_key_encrypted\x18\x08 \x01(\x08H\x05\x88\x01\x01\x42\x06\n\x04_pskB\r\n\x0b_dhchap_keyB\x10\n\x0e_psk_encryptedB\x10\n\x0e_key_encryptedB\x13\n\x11_dhchap_ctrlr_keyB\x16\n\x14_ctrlr_key_encrypted\"\x9a\x01\n\x13\x63hange_host_key_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x17\n\ndhchap_key\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x1d\n\x10\x64hchap_ctrlr_key\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\r\n\x0b_dhchap_keyB\x13\n\x11_dhchap_ctrlr_key\"i\n get_connection_io_statistics_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x12\n\x05reset\x18\x03 \x01(\x08H\x00\x88\x01\x01\x42\x08\n\x06_reset\"\x8c\x01\n\x0fremove_host_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\x12\x12\n\x05\x66orce\x18\x03 \x01(\x08H\x00\x88\x01\x01\x12\x1d\n\x10keep_connections\x18\x04 \x01(\x08H\x01\x88\x01\x01\x42\x08\n\x06_forceB\x13\n\x11_keep_connections\"F\n\x1bset_keep_host_connected_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\x12\x10\n\x08host_nqn\x18\x02 \x01(\t\"O\n\x0elist_hosts_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x19\n\x0c\x63lear_alerts\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_clear_alerts\"U\n\x14list_connections_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\x12\x19\n\x0c\x63lear_alerts\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_clear_alerts\"\x89\x02\n\x13\x63reate_listener_req\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\thost_name\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12#\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamilyH\x00\x88\x01\x01\x12\x14\n\x07trsvcid\x18\x06 \x01(\rH\x01\x88\x01\x01\x12\x13\n\x06secure\x18\x07 \x01(\x08H\x02\x88\x01\x01\x12\x1d\n\x10verify_host_name\x18\x08 \x01(\x08H\x03\x88\x01\x01\x12\x12\n\x05\x66orce\x18\t \x01(\x08H\x04\x88\x01\x01\x42\t\n\x07_adrfamB\n\n\x08_trsvcidB\t\n\x07_secureB\x13\n\x11_verify_host_nameB\x08\n\x06_force\"\xb5\x01\n\x13\x64\x65lete_listener_req\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\thost_name\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12#\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamilyH\x00\x88\x01\x01\x12\x14\n\x07trsvcid\x18\x06 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05\x66orce\x18\x07 \x01(\x08H\x02\x88\x01\x01\x42\t\n\x07_adrfamB\n\n\x08_trsvcidB\x08\n\x06_force\"\'\n\x12list_listeners_req\x12\x11\n\tsubsystem\x18\x01 \x01(\t\"q\n\x13list_subsystems_req\x12\x1a\n\rsubsystem_nqn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1a\n\rserial_number\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x10\n\x0e_subsystem_nqnB\x10\n\x0e_serial_number\"\x14\n\x12get_subsystems_req\"U\n%get_spdk_nvmf_log_flags_and_level_req\x12\x1a\n\rall_log_flags\x18\x01 \x01(\x08H\x00\x88\x01\x01\x42\x10\n\x0e_all_log_flags\"5\n\x1a\x64isable_spdk_nvmf_logs_req\x12\x17\n\x0f\x65xtra_log_flags\x18\x01 \x03(\t\"\x97\x01\n\x16set_spdk_nvmf_logs_req\x12!\n\tlog_level\x18\x01 \x01(\x0e\x32\t.LogLevelH\x00\x88\x01\x01\x12#\n\x0bprint_level\x18\x02 \x01(\x0e\x32\t.LogLevelH\x01\x88\x01\x01\x12\x17\n\x0f\x65xtra_log_flags\x18\x03 \x03(\tB\x0c\n\n_log_levelB\x0e\n\x0c_print_level\"@\n\x14get_gateway_info_req\x12\x18\n\x0b\x63li_version\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_cli_version\"\x1b\n\x19get_gateway_log_level_req\";\n\x19set_gateway_log_level_req\x12\x1e\n\tlog_level\x18\x01 \x01(\x0e\x32\x0b.GwLogLevel\"8\n\x1fshow_gateway_listeners_info_req\x12\x15\n\rsubsystem_nqn\x18\x01 \x01(\t\"\x17\n\x15get_gateway_stats_req\"\x16\n\x14get_thread_stats_req\"0\n\x1dset_gateway_io_stats_mode_req\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\"<\n\x0f\x61na_group_state\x12\x0e\n\x06grp_id\x18\x01 \x01(\r\x12\x19\n\x05state\x18\x02 \x01(\x0e\x32\n.ana_state\"?\n\x0enqn_ana_states\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12 \n\x06states\x18\x02 \x03(\x0b\x32\x10.ana_group_state\"+\n\x08\x61na_info\x12\x1f\n\x06states\x18\x01 \x03(\x0b\x32\x0f.nqn_ana_states\"3\n\nreq_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\"C\n\rsubsys_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0b\n\x03nqn\x18\x03 \x01(\t\"B\n\x0bnsid_status\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0c\n\x04nsid\x18\x03 \x01(\r\"1\n\x0fsubsystems_info\x12\x1e\n\nsubsystems\x18\x01 \x03(\x0b\x32\n.subsystem\"\xc2\x03\n\tsubsystem\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x0f\n\x07subtype\x18\x02 \x01(\t\x12)\n\x10listen_addresses\x18\x03 \x03(\x0b\x32\x0f.listen_address\x12\x14\n\x05hosts\x18\x04 \x03(\x0b\x32\x05.host\x12\x16\n\x0e\x61llow_any_host\x18\x05 \x01(\x08\x12\x1a\n\rserial_number\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0cmodel_number\x18\x07 \x01(\tH\x01\x88\x01\x01\x12\x1b\n\x0emax_namespaces\x18\x08 \x01(\rH\x02\x88\x01\x01\x12\x17\n\nmin_cntlid\x18\t \x01(\rH\x03\x88\x01\x01\x12\x17\n\nmax_cntlid\x18\n \x01(\rH\x04\x88\x01\x01\x12\x1e\n\nnamespaces\x18\x0b \x03(\x0b\x32\n.namespace\x12\x1b\n\x0ehas_dhchap_key\x18\x0c \x01(\x08H\x05\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\r \x03(\tB\x10\n\x0e_serial_numberB\x0f\n\r_model_numberB\x11\n\x0f_max_namespacesB\r\n\x0b_min_cntlidB\r\n\x0b_max_cntlidB\x11\n\x0f_has_dhchap_key\"\x97\x01\n\x0elisten_address\x12\x0e\n\x06trtype\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x64rfam\x18\x02 \x01(\t\x12\x0e\n\x06traddr\x18\x03 \x01(\t\x12\x0f\n\x07trsvcid\x18\x04 \x01(\t\x12\x16\n\ttransport\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06secure\x18\x06 \x01(\x08H\x01\x88\x01\x01\x42\x0c\n\n_transportB\t\n\x07_secure\"\xc0\x02\n\tnamespace\x12\x0c\n\x04nsid\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\tbdev_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05nguid\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04uuid\x18\x05 \x01(\tH\x02\x88\x01\x01\x12\x15\n\x08\x61nagrpid\x18\x06 \x01(\rH\x03\x88\x01\x01\x12\x12\n\x05nonce\x18\x07 \x01(\tH\x04\x88\x01\x01\x12\x19\n\x0c\x61uto_visible\x18\x08 \x01(\x08H\x05\x88\x01\x01\x12\r\n\x05hosts\x18\t \x03(\t\x12!\n\x14rados_namespace_name\x18\n \x01(\tH\x06\x88\x01\x01\x42\x0c\n\n_bdev_nameB\x08\n\x06_nguidB\x07\n\x05_uuidB\x0b\n\t_anagrpidB\x08\n\x06_nonceB\x0f\n\r_auto_visibleB\x17\n\x15_rados_namespace_name\"`\n\x13subsystems_info_cli\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\"\n\nsubsystems\x18\x03 \x03(\x0b\x32\x0e.subsystem_cli\"\xf6\x02\n\rsubsystem_cli\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x11\n\tenable_ha\x18\x02 \x01(\x08\x12\x15\n\rserial_number\x18\x03 \x01(\t\x12\x14\n\x0cmodel_number\x18\x04 \x01(\t\x12\x12\n\nmin_cntlid\x18\x05 \x01(\r\x12\x12\n\nmax_cntlid\x18\x06 \x01(\r\x12\x17\n\x0fnamespace_count\x18\x07 \x01(\r\x12\x0f\n\x07subtype\x18\x08 \x01(\t\x12\x16\n\x0emax_namespaces\x18\t \x01(\r\x12\x1b\n\x0ehas_dhchap_key\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x1b\n\x0e\x61llow_any_host\x18\x0b \x01(\x08H\x01\x88\x01\x01\x12 \n\x13\x63reated_without_key\x18\x0c \x01(\x08H\x02\x88\x01\x01\x12\x14\n\x0cnetwork_mask\x18\r \x03(\tB\x11\n\x0f_has_dhchap_keyB\x11\n\x0f_allow_any_hostB\x16\n\x14_created_without_key\"\xbb\x05\n\x0cgateway_info\x12\x13\n\x0b\x63li_version\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\r\n\x05group\x18\x04 \x01(\t\x12\x0c\n\x04\x61\x64\x64r\x18\x05 \x01(\t\x12\x0c\n\x04port\x18\x06 \x01(\t\x12\x13\n\x0b\x62ool_status\x18\x07 \x01(\x08\x12\x0e\n\x06status\x18\x08 \x01(\x05\x12\x15\n\rerror_message\x18\t \x01(\t\x12\x19\n\x0cspdk_version\x18\n \x01(\tH\x00\x88\x01\x01\x12\x1c\n\x14load_balancing_group\x18\x0b \x01(\r\x12\x10\n\x08hostname\x18\x0c \x01(\t\x12\x1b\n\x0emax_subsystems\x18\r \x01(\rH\x01\x88\x01\x01\x12\x1b\n\x0emax_namespaces\x18\x0e \x01(\rH\x02\x88\x01\x01\x12$\n\x17max_hosts_per_subsystem\x18\x0f \x01(\rH\x03\x88\x01\x01\x12)\n\x1cmax_namespaces_per_subsystem\x18\x10 \x01(\rH\x04\x88\x01\x01\x12\x16\n\tmax_hosts\x18\x11 \x01(\rH\x05\x88\x01\x01\x12(\n\x1bgateway_initialization_over\x18\x12 \x01(\x08H\x06\x88\x01\x01\x12\x1d\n\x10io_stats_enabled\x18\x13 \x01(\x08H\x07\x88\x01\x01\x12\x15\n\x08location\x18\x14 \x01(\tH\x08\x88\x01\x01\x42\x0f\n\r_spdk_versionB\x11\n\x0f_max_subsystemsB\x11\n\x0f_max_namespacesB\x1a\n\x18_max_hosts_per_subsystemB\x1f\n\x1d_max_namespaces_per_subsystemB\x0c\n\n_max_hostsB\x1e\n\x1c_gateway_initialization_overB\x13\n\x11_io_stats_enabledB\x0b\n\t_location\"E\n\x0b\x63li_version\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\"D\n\ngw_version\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\"+\n\x19poll_group_transport_info\x12\x0e\n\x06trtype\x18\x01 \x01(\t\"\xe5\x01\n\x0fpoll_group_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x61\x64min_qpairs\x18\x02 \x01(\r\x12\x11\n\tio_qpairs\x18\x03 \x01(\r\x12\x1c\n\x14\x63urrent_admin_qpairs\x18\x04 \x01(\r\x12\x19\n\x11\x63urrent_io_qpairs\x18\x05 \x01(\r\x12\x17\n\x0fpending_bdev_io\x18\x06 \x01(\x04\x12\x19\n\x11\x63ompleted_nvme_io\x18\x07 \x01(\x04\x12.\n\ntransports\x18\x08 \x03(\x0b\x32\x1a.poll_group_transport_info\"u\n\x12gateway_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x11\n\ttick_rate\x18\x03 \x01(\x04\x12%\n\x0bpoll_groups\x18\x04 \x03(\x0b\x32\x10.poll_group_info\"q\n\x11thread_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\"\n\x07threads\x18\x03 \x03(\x0b\x32\x11.spdk_thread_info\x12\x11\n\ttick_rate\x18\x04 \x01(\x04\"<\n\x10spdk_thread_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x62usy\x18\x02 \x01(\x04\x12\x0c\n\x04idle\x18\x03 \x01(\x04\"\xd3\x01\n\rlistener_info\x12\x11\n\thost_name\x18\x01 \x01(\t\x12\x0e\n\x06trtype\x18\x02 \x01(\t\x12\x1e\n\x06\x61\x64rfam\x18\x03 \x01(\x0e\x32\x0e.AddressFamily\x12\x0e\n\x06traddr\x18\x04 \x01(\t\x12\x0f\n\x07trsvcid\x18\x05 \x01(\r\x12\x13\n\x06secure\x18\x06 \x01(\x08H\x00\x88\x01\x01\x12\x13\n\x06\x61\x63tive\x18\x07 \x01(\x08H\x01\x88\x01\x01\x12\x13\n\x06manual\x18\x08 \x01(\x08H\x02\x88\x01\x01\x42\t\n\x07_secureB\t\n\x07_activeB\t\n\x07_manual\"Z\n\x0elisteners_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12!\n\tlisteners\x18\x03 \x03(\x0b\x32\x0e.listener_info\"^\n\x15gateway_listener_info\x12 \n\x08listener\x18\x01 \x01(\x0b\x32\x0e.listener_info\x12#\n\tlb_states\x18\x02 \x03(\x0b\x32\x10.ana_group_state\"m\n\x16gateway_listeners_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12,\n\x0cgw_listeners\x18\x03 \x03(\x0b\x32\x16.gateway_listener_info\"\x9b\x02\n\x04host\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x14\n\x07use_psk\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x17\n\nuse_dhchap\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12\x32\n%disconnected_due_to_keepalive_timeout\x18\x04 \x01(\x08H\x02\x88\x01\x01\x12\x41\n\x18\x64hchap_controller_origin\x18\x05 \x01(\x0e\x32\x1a.DHCHAPControllerKeyOriginH\x03\x88\x01\x01\x42\n\n\x08_use_pskB\r\n\x0b_use_dhchapB(\n&_disconnected_due_to_keepalive_timeoutB\x1b\n\x19_dhchap_controller_origin\"7\n\rlatency_stats\x12\x0b\n\x03min\x18\x01 \x01(\x04\x12\x0b\n\x03max\x18\x02 \x01(\x04\x12\x0c\n\x04mean\x18\x03 \x01(\x04\"\x98\x01\n\rlatency_group\x12\x10\n\x08io_count\x18\x01 \x01(\x04\x12\x1d\n\x05total\x18\x02 \x01(\x0b\x32\x0e.latency_stats\x12\x1c\n\x04\x62\x64\x65v\x18\x03 \x01(\x0b\x32\x0e.latency_stats\x12\x1b\n\x03net\x18\x04 \x01(\x0b\x32\x0e.latency_stats\x12\x1b\n\x03qos\x18\x05 \x01(\x0b\x32\x0e.latency_stats\"X\n\x0b\x62ucket_info\x12\x0c\n\x04size\x18\x01 \x01(\r\x12\x1c\n\x04read\x18\x02 \x01(\x0b\x32\x0e.latency_group\x12\x1d\n\x05write\x18\x03 \x01(\x0b\x32\x0e.latency_group\"\xb7\x01\n\x18\x63onnection_io_statistics\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x10\n\x08host_nqn\x18\x04 \x01(\t\x12\x1a\n\rtotal_num_ios\x18\x05 \x01(\x04H\x00\x88\x01\x01\x12\x1d\n\x07\x62uckets\x18\x06 \x03(\x0b\x32\x0c.bucket_infoB\x10\n\x0e_total_num_ios\"x\n\nhosts_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x16\n\x0e\x61llow_any_host\x18\x03 \x01(\x08\x12\x15\n\rsubsystem_nqn\x18\x04 \x01(\t\x12\x14\n\x05hosts\x18\x05 \x03(\x0b\x32\x05.host\"\xa4\x04\n\nconnection\x12\x0b\n\x03nqn\x18\x01 \x01(\t\x12\x0e\n\x06traddr\x18\x02 \x01(\t\x12\x0f\n\x07trsvcid\x18\x03 \x01(\r\x12\x0e\n\x06trtype\x18\x04 \x01(\t\x12\x1e\n\x06\x61\x64rfam\x18\x05 \x01(\x0e\x32\x0e.AddressFamily\x12\x11\n\tconnected\x18\x06 \x01(\x08\x12\x14\n\x0cqpairs_count\x18\x07 \x01(\x05\x12\x15\n\rcontroller_id\x18\x08 \x01(\x05\x12\x13\n\x06secure\x18\t \x01(\x08H\x00\x88\x01\x01\x12\x14\n\x07use_psk\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x17\n\nuse_dhchap\x18\x0b \x01(\x08H\x02\x88\x01\x01\x12\x16\n\tsubsystem\x18\x0c \x01(\tH\x03\x88\x01\x01\x12\x32\n%disconnected_due_to_keepalive_timeout\x18\r \x01(\x08H\x04\x88\x01\x01\x12\x41\n\x18\x64hchap_controller_origin\x18\x0e \x01(\x0e\x32\x1a.DHCHAPControllerKeyOriginH\x05\x88\x01\x01\x12\x19\n\x0chost_deleted\x18\x0f \x01(\x08H\x06\x88\x01\x01\x42\t\n\x07_secureB\n\n\x08_use_pskB\r\n\x0b_use_dhchapB\x0c\n\n_subsystemB(\n&_disconnected_due_to_keepalive_timeoutB\x1b\n\x19_dhchap_controller_originB\x0f\n\r_host_deleted\"r\n\x10\x63onnections_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12 \n\x0b\x63onnections\x18\x04 \x03(\x0b\x32\x0b.connection\"\xd8\x07\n\rnamespace_cli\x12\x0c\n\x04nsid\x18\x01 \x01(\r\x12\x11\n\tbdev_name\x18\x02 \x01(\t\x12\x16\n\x0erbd_image_name\x18\x03 \x01(\t\x12\x15\n\rrbd_pool_name\x18\x04 \x01(\t\x12\x1c\n\x14load_balancing_group\x18\x05 \x01(\r\x12\x12\n\nblock_size\x18\x06 \x01(\r\x12\x16\n\x0erbd_image_size\x18\x07 \x01(\x04\x12\x0c\n\x04uuid\x18\x08 \x01(\t\x12\x19\n\x11rw_ios_per_second\x18\t \x01(\x04\x12\x1c\n\x14rw_mbytes_per_second\x18\n \x01(\x04\x12\x1b\n\x13r_mbytes_per_second\x18\x0b \x01(\x04\x12\x1b\n\x13w_mbytes_per_second\x18\x0c \x01(\x04\x12\x14\n\x0c\x61uto_visible\x18\r \x01(\x08\x12\r\n\x05hosts\x18\x0e \x03(\t\x12\x1d\n\x10ns_subsystem_nqn\x18\x0f \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0btrash_image\x18\x10 \x01(\x08H\x01\x88\x01\x01\x12 \n\x13\x64isable_auto_resize\x18\x11 \x01(\x08H\x02\x88\x01\x01\x12\x16\n\tread_only\x18\x12 \x01(\x08H\x03\x88\x01\x01\x12\x19\n\x0c\x63luster_name\x18\x13 \x01(\tH\x04\x88\x01\x01\x12,\n\x1f\x63onfigured_load_balancing_group\x18\x14 \x01(\rH\x05\x88\x01\x01\x12\x1d\n\x10image_was_shrunk\x18\x15 \x01(\x08H\x06\x88\x01\x01\x12\x1f\n\x12rbd_data_pool_name\x18\x16 \x01(\tH\x07\x88\x01\x01\x12\x15\n\x08location\x18\x17 \x01(\tH\x08\x88\x01\x01\x12!\n\x14rados_namespace_name\x18\x18 \x01(\tH\t\x88\x01\x01\x12\x37\n\x14\x65ncryption_algorithm\x18\x19 \x01(\x0e\x32\x14.EncryptionAlgorithmH\n\x88\x01\x01\x12-\n\x12\x65ncryption_entries\x18\x1a \x03(\x0b\x32\x11.encryption_entryB\x13\n\x11_ns_subsystem_nqnB\x0e\n\x0c_trash_imageB\x16\n\x14_disable_auto_resizeB\x0c\n\n_read_onlyB\x0f\n\r_cluster_nameB\"\n _configured_load_balancing_groupB\x13\n\x11_image_was_shrunkB\x15\n\x13_rbd_data_pool_nameB\x0b\n\t_locationB\x17\n\x15_rados_namespace_nameB\x17\n\x15_encryption_algorithm\"s\n\x0fnamespaces_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\"\n\nnamespaces\x18\x04 \x03(\x0b\x32\x0e.namespace_cli\"1\n\x12namespace_io_error\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\r\"\x91\x01\n\x1dlist_namespaces_io_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x11\n\ttick_rate\x18\x03 \x01(\x04\x12\r\n\x05ticks\x18\x04 \x01(\x04\x12\'\n\nnamespaces\x18\x05 \x03(\x0b\x32\x13.bdev_io_stats_info\"\xf7\x04\n\x12\x62\x64\x65v_io_stats_info\x12\x11\n\tbdev_name\x18\x01 \x01(\t\x12\x12\n\nbytes_read\x18\x02 \x01(\x04\x12\x14\n\x0cnum_read_ops\x18\x03 \x01(\x04\x12\x15\n\rbytes_written\x18\x04 \x01(\x04\x12\x15\n\rnum_write_ops\x18\x05 \x01(\x04\x12\x16\n\x0e\x62ytes_unmapped\x18\x06 \x01(\x04\x12\x15\n\rnum_unmap_ops\x18\x07 \x01(\x04\x12\x14\n\x0c\x62ytes_copied\x18\x08 \x01(\x04\x12\x14\n\x0cnum_copy_ops\x18\t \x01(\x04\x12\x1a\n\x12read_latency_ticks\x18\n \x01(\x04\x12\x1e\n\x16max_read_latency_ticks\x18\x0b \x01(\x04\x12\x1e\n\x16min_read_latency_ticks\x18\x0c \x01(\x04\x12\x1b\n\x13write_latency_ticks\x18\r \x01(\x04\x12\x1f\n\x17max_write_latency_ticks\x18\x0e \x01(\x04\x12\x1f\n\x17min_write_latency_ticks\x18\x0f \x01(\x04\x12\x1b\n\x13unmap_latency_ticks\x18\x10 \x01(\x04\x12\x1f\n\x17max_unmap_latency_ticks\x18\x11 \x01(\x04\x12\x1f\n\x17min_unmap_latency_ticks\x18\x12 \x01(\x04\x12\x1a\n\x12\x63opy_latency_ticks\x18\x13 \x01(\x04\x12\x1e\n\x16max_copy_latency_ticks\x18\x14 \x01(\x04\x12\x1e\n\x16min_copy_latency_ticks\x18\x15 \x01(\x04\x12%\n\x08io_error\x18\x16 \x03(\x0b\x32\x13.namespace_io_error\"\xda\x05\n\x17namespace_io_stats_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x15\n\rsubsystem_nqn\x18\x03 \x01(\t\x12\x0c\n\x04nsid\x18\x04 \x01(\r\x12\x11\n\x04uuid\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x11\n\tbdev_name\x18\x06 \x01(\t\x12\x11\n\ttick_rate\x18\x07 \x01(\x04\x12\r\n\x05ticks\x18\x08 \x01(\x04\x12\x12\n\nbytes_read\x18\t \x01(\x04\x12\x14\n\x0cnum_read_ops\x18\n \x01(\x04\x12\x15\n\rbytes_written\x18\x0b \x01(\x04\x12\x15\n\rnum_write_ops\x18\x0c \x01(\x04\x12\x16\n\x0e\x62ytes_unmapped\x18\r \x01(\x04\x12\x15\n\rnum_unmap_ops\x18\x0e \x01(\x04\x12\x1a\n\x12read_latency_ticks\x18\x0f \x01(\x04\x12\x1e\n\x16max_read_latency_ticks\x18\x10 \x01(\x04\x12\x1e\n\x16min_read_latency_ticks\x18\x11 \x01(\x04\x12\x1b\n\x13write_latency_ticks\x18\x12 \x01(\x04\x12\x1f\n\x17max_write_latency_ticks\x18\x13 \x01(\x04\x12\x1f\n\x17min_write_latency_ticks\x18\x14 \x01(\x04\x12\x1b\n\x13unmap_latency_ticks\x18\x15 \x01(\x04\x12\x1f\n\x17max_unmap_latency_ticks\x18\x16 \x01(\x04\x12\x1f\n\x17min_unmap_latency_ticks\x18\x17 \x01(\x04\x12\x1a\n\x12\x63opy_latency_ticks\x18\x18 \x01(\x04\x12\x1e\n\x16max_copy_latency_ticks\x18\x19 \x01(\x04\x12\x1e\n\x16min_copy_latency_ticks\x18\x1a \x01(\x04\x12%\n\x08io_error\x18\x1b \x03(\x0b\x32\x13.namespace_io_errorB\x07\n\x05_uuid\"3\n\x12spdk_log_flag_info\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08\"\xba\x01\n\"spdk_nvmf_log_flags_and_level_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12+\n\x0envmf_log_flags\x18\x03 \x03(\x0b\x32\x13.spdk_log_flag_info\x12\x1c\n\tlog_level\x18\x04 \x01(\x0e\x32\t.LogLevel\x12\"\n\x0flog_print_level\x18\x05 \x01(\x0e\x32\t.LogLevel\"_\n\x16gateway_log_level_info\x12\x0e\n\x06status\x18\x01 \x01(\x05\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12\x1e\n\tlog_level\x18\x03 \x01(\x0e\x32\x0b.GwLogLevel\"E\n\x10\x65ncryption_entry\x12!\n\x06\x66ormat\x18\x01 \x01(\x0e\x32\x11.EncryptionFormat\x12\x0e\n\x06key_id\x18\x02 \x01(\t*#\n\rAddressFamily\x12\x08\n\x04ipv4\x10\x00\x12\x08\n\x04ipv6\x10\x01*C\n\x08LogLevel\x12\t\n\x05\x45RROR\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\n\n\x06NOTICE\x10\x02\x12\x08\n\x04INFO\x10\x03\x12\t\n\x05\x44\x45\x42UG\x10\x04*S\n\nGwLogLevel\x12\n\n\x06notset\x10\x00\x12\t\n\x05\x64\x65\x62ug\x10\n\x12\x08\n\x04info\x10\x14\x12\x0b\n\x07warning\x10\x1e\x12\t\n\x05\x65rror\x10(\x12\x0c\n\x08\x63ritical\x10\x32*2\n\x10\x45ncryptionFormat\x12\x08\n\x04none\x10\x00\x12\t\n\x05luks1\x10\x01\x12\t\n\x05luks2\x10\x02*?\n\x13\x45ncryptionAlgorithm\x12\x10\n\x0cno_algorithm\x10\x00\x12\n\n\x06\x61\x65s128\x10\x01\x12\n\n\x06\x61\x65s256\x10\x02*R\n\x19\x44HCHAPControllerKeyOrigin\x12\n\n\x06no_key\x10\x00\x12\x11\n\rhost_specific\x10\x01\x12\x16\n\x12subsystem_implicit\x10\x02*J\n\tana_state\x12\t\n\x05UNSET\x10\x00\x12\r\n\tOPTIMIZED\x10\x01\x12\x11\n\rNON_OPTIMIZED\x10\x02\x12\x10\n\x0cINACCESSIBLE\x10\x03\x32\xf6\x18\n\x07Gateway\x12\x33\n\rnamespace_add\x12\x12.namespace_add_req\x1a\x0c.nsid_status\"\x00\x12;\n\x10\x63reate_subsystem\x12\x15.create_subsystem_req\x1a\x0e.subsys_status\"\x00\x12\x38\n\x10\x64\x65lete_subsystem\x12\x15.delete_subsystem_req\x1a\x0b.req_status\"\x00\x12@\n\x14\x63hange_subsystem_key\x12\x19.change_subsystem_key_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15\x61\x64\x64_subsystem_network\x12\x1a.add_subsystem_network_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15\x64\x65l_subsystem_network\x12\x1a.del_subsystem_network_req\x1a\x0b.req_status\"\x00\x12K\n\x12gw_refresh_network\x12\x17.gw_refresh_network_req\x1a\x1a.gw_refresh_network_status\"\x00\x12J\n\x19\x61\x64\x64_kmip_server_endpoints\x12\x1e.add_kmip_server_endpoints_req\x1a\x0b.req_status\"\x00\x12J\n\x19\x64\x65l_kmip_server_endpoints\x12\x1e.del_kmip_server_endpoints_req\x1a\x0b.req_status\"\x00\x12\\\n\x1alist_kmip_server_endpoints\x12\x1f.list_kmip_server_endpoints_req\x1a\x1b.kmip_server_endpoints_info\"\x00\x12;\n\x0flist_namespaces\x12\x14.list_namespaces_req\x1a\x10.namespaces_info\"\x00\x12\x38\n\x10namespace_resize\x12\x15.namespace_resize_req\x1a\x0b.req_status\"\x00\x12Q\n\x16namespace_get_io_stats\x12\x1b.namespace_get_io_stats_req\x1a\x18.namespace_io_stats_info\"\x00\x12[\n\x18list_namespaces_io_stats\x12\x1d.list_namespaces_io_stats_req\x1a\x1e.list_namespaces_io_stats_info\"\x00\x12\x41\n\x18namespace_set_qos_limits\x12\x16.namespace_set_qos_req\x1a\x0b.req_status\"\x00\x12\x62\n%namespace_change_load_balancing_group\x12*.namespace_change_load_balancing_group_req\x1a\x0b.req_status\"\x00\x12N\n\x1bnamespace_change_visibility\x12 .namespace_change_visibility_req\x1a\x0b.req_status\"\x00\x12J\n\x19namespace_change_location\x12\x1e.namespace_change_location_req\x1a\x0b.req_status\"\x00\x12R\n\x1dnamespace_set_rbd_trash_image\x12\".namespace_set_rbd_trash_image_req\x1a\x0b.req_status\"\x00\x12J\n\x19namespace_set_auto_resize\x12\x1e.namespace_set_auto_resize_req\x1a\x0b.req_status\"\x00\x12\x38\n\x10namespace_delete\x12\x15.namespace_delete_req\x1a\x0b.req_status\"\x00\x12<\n\x12namespace_add_host\x12\x17.namespace_add_host_req\x1a\x0b.req_status\"\x00\x12\x42\n\x15namespace_delete_host\x12\x1a.namespace_delete_host_req\x1a\x0b.req_status\"\x00\x12(\n\x08\x61\x64\x64_host\x12\r.add_host_req\x1a\x0b.req_status\"\x00\x12.\n\x0bremove_host\x12\x10.remove_host_req\x1a\x0b.req_status\"\x00\x12\x46\n\x17set_keep_host_connected\x12\x1c.set_keep_host_connected_req\x1a\x0b.req_status\"\x00\x12\x36\n\x0f\x63hange_host_key\x12\x14.change_host_key_req\x1a\x0b.req_status\"\x00\x12,\n\nlist_hosts\x12\x0f.list_hosts_req\x1a\x0b.hosts_info\"\x00\x12>\n\x10list_connections\x12\x15.list_connections_req\x1a\x11.connections_info\"\x00\x12^\n\x1cget_connection_io_statistics\x12!.get_connection_io_statistics_req\x1a\x19.connection_io_statistics\"\x00\x12\x36\n\x0f\x63reate_listener\x12\x14.create_listener_req\x1a\x0b.req_status\"\x00\x12\x36\n\x0f\x64\x65lete_listener\x12\x14.delete_listener_req\x1a\x0b.req_status\"\x00\x12\x38\n\x0elist_listeners\x12\x13.list_listeners_req\x1a\x0f.listeners_info\"\x00\x12?\n\x0flist_subsystems\x12\x14.list_subsystems_req\x1a\x14.subsystems_info_cli\"\x00\x12\x39\n\x0eget_subsystems\x12\x13.get_subsystems_req\x1a\x10.subsystems_info\"\x00\x12)\n\rset_ana_state\x12\t.ana_info\x1a\x0b.req_status\"\x00\x12r\n!get_spdk_nvmf_log_flags_and_level\x12&.get_spdk_nvmf_log_flags_and_level_req\x1a#.spdk_nvmf_log_flags_and_level_info\"\x00\x12\x44\n\x16\x64isable_spdk_nvmf_logs\x12\x1b.disable_spdk_nvmf_logs_req\x1a\x0b.req_status\"\x00\x12<\n\x12set_spdk_nvmf_logs\x12\x17.set_spdk_nvmf_logs_req\x1a\x0b.req_status\"\x00\x12:\n\x10get_gateway_info\x12\x15.get_gateway_info_req\x1a\r.gateway_info\"\x00\x12N\n\x15get_gateway_log_level\x12\x1a.get_gateway_log_level_req\x1a\x17.gateway_log_level_info\"\x00\x12\x42\n\x15set_gateway_log_level\x12\x1a.set_gateway_log_level_req\x1a\x0b.req_status\"\x00\x12Z\n\x1bshow_gateway_listeners_info\x12 .show_gateway_listeners_info_req\x1a\x17.gateway_listeners_info\"\x00\x12\x42\n\x11get_gateway_stats\x12\x16.get_gateway_stats_req\x1a\x13.gateway_stats_info\"\x00\x12?\n\x10get_thread_stats\x12\x15.get_thread_stats_req\x1a\x12.thread_stats_info\"\x00\x12J\n\x19set_gateway_io_stats_mode\x12\x1e.set_gateway_io_stats_mode_req\x1a\x0b.req_status\"\x00\x42+Z)github.com/ceph/ceph-nvmeof/lib/go/nvmeofb\x06proto3' ) _ADDRESSFAMILY = _descriptor.EnumDescriptor( @@ -43,8 +43,8 @@ _ADDRESSFAMILY = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14328, - serialized_end=14363, + serialized_start=14706, + serialized_end=14741, ) _sym_db.RegisterEnumDescriptor(_ADDRESSFAMILY) @@ -84,8 +84,8 @@ _LOGLEVEL = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14365, - serialized_end=14432, + serialized_start=14743, + serialized_end=14810, ) _sym_db.RegisterEnumDescriptor(_LOGLEVEL) @@ -130,8 +130,8 @@ _GWLOGLEVEL = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14434, - serialized_end=14517, + serialized_start=14812, + serialized_end=14895, ) _sym_db.RegisterEnumDescriptor(_GWLOGLEVEL) @@ -161,8 +161,8 @@ _ENCRYPTIONFORMAT = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14519, - serialized_end=14569, + serialized_start=14897, + serialized_end=14947, ) _sym_db.RegisterEnumDescriptor(_ENCRYPTIONFORMAT) @@ -192,8 +192,8 @@ _ENCRYPTIONALGORITHM = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14571, - serialized_end=14634, + serialized_start=14949, + serialized_end=15012, ) _sym_db.RegisterEnumDescriptor(_ENCRYPTIONALGORITHM) @@ -223,8 +223,8 @@ _DHCHAPCONTROLLERKEYORIGIN = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14636, - serialized_end=14718, + serialized_start=15014, + serialized_end=15096, ) _sym_db.RegisterEnumDescriptor(_DHCHAPCONTROLLERKEYORIGIN) @@ -259,8 +259,8 @@ _ANA_STATE = _descriptor.EnumDescriptor( ], containing_type=None, serialized_options=None, - serialized_start=14720, - serialized_end=14794, + serialized_start=15098, + serialized_end=15172, ) _sym_db.RegisterEnumDescriptor(_ANA_STATE) @@ -1532,6 +1532,91 @@ _DEL_SUBSYSTEM_NETWORK_REQ = _descriptor.Descriptor( ) +_GW_REFRESH_NETWORK_REQ = _descriptor.Descriptor( + name='gw_refresh_network_req', + full_name='gw_refresh_network_req', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='subsystem_nqn', full_name='gw_refresh_network_req.subsystem_nqn', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3210, + serialized_end=3257, +) + + +_GW_REFRESH_NETWORK_STATUS = _descriptor.Descriptor( + name='gw_refresh_network_status', + full_name='gw_refresh_network_status', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='status', full_name='gw_refresh_network_status.status', index=0, + number=1, type=5, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='error_message', full_name='gw_refresh_network_status.error_message', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='added', full_name='gw_refresh_network_status.added', index=2, + number=3, type=9, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='removed', full_name='gw_refresh_network_status.removed', index=3, + number=4, type=9, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3259, + serialized_end=3357, +) + + _KMIP_SERVER_ENDPOINT = _descriptor.Descriptor( name='kmip_server_endpoint', full_name='kmip_server_endpoint', @@ -1571,8 +1656,8 @@ _KMIP_SERVER_ENDPOINT = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=3210, - serialized_end=3277, + serialized_start=3359, + serialized_end=3426, ) @@ -1624,8 +1709,8 @@ _KMIP_SERVER_ENDPOINT_CLI = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=3279, - serialized_end=3380, + serialized_start=3428, + serialized_end=3529, ) @@ -1670,8 +1755,8 @@ _ADD_KMIP_SERVER_ENDPOINTS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=3382, - serialized_end=3499, + serialized_start=3531, + serialized_end=3648, ) @@ -1716,8 +1801,8 @@ _DEL_KMIP_SERVER_ENDPOINTS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=3501, - serialized_end=3618, + serialized_start=3650, + serialized_end=3767, ) @@ -1765,8 +1850,8 @@ _LIST_KMIP_SERVER_ENDPOINTS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=3620, - serialized_end=3740, + serialized_start=3769, + serialized_end=3889, ) @@ -1811,8 +1896,8 @@ _KMIP_SERVER_ENDPOINTS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=3742, - serialized_end=3855, + serialized_start=3891, + serialized_end=4004, ) @@ -1867,8 +1952,8 @@ _LIST_NAMESPACES_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=3857, - serialized_end=3953, + serialized_start=4006, + serialized_end=4102, ) @@ -1978,8 +2063,8 @@ _ADD_HOST_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=3956, - serialized_end=4279, + serialized_start=4105, + serialized_end=4428, ) @@ -2041,8 +2126,8 @@ _CHANGE_HOST_KEY_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=4282, - serialized_end=4436, + serialized_start=4431, + serialized_end=4585, ) @@ -2092,8 +2177,8 @@ _GET_CONNECTION_IO_STATISTICS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=4438, - serialized_end=4543, + serialized_start=4587, + serialized_end=4692, ) @@ -2126,6 +2211,13 @@ _REMOVE_HOST_REQ = _descriptor.Descriptor( message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='keep_connections', full_name='remove_host_req.keep_connections', index=3, + number=4, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -2142,9 +2234,53 @@ _REMOVE_HOST_REQ = _descriptor.Descriptor( index=0, containing_type=None, create_key=_descriptor._internal_create_key, fields=[]), + _descriptor.OneofDescriptor( + name='_keep_connections', full_name='remove_host_req._keep_connections', + index=1, containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[]), ], - serialized_start=4545, - serialized_end=4633, + serialized_start=4695, + serialized_end=4835, +) + + +_SET_KEEP_HOST_CONNECTED_REQ = _descriptor.Descriptor( + name='set_keep_host_connected_req', + full_name='set_keep_host_connected_req', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='subsystem_nqn', full_name='set_keep_host_connected_req.subsystem_nqn', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='host_nqn', full_name='set_keep_host_connected_req.host_nqn', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=4837, + serialized_end=4907, ) @@ -2187,8 +2323,8 @@ _LIST_HOSTS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=4635, - serialized_end=4714, + serialized_start=4909, + serialized_end=4988, ) @@ -2231,8 +2367,8 @@ _LIST_CONNECTIONS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=4716, - serialized_end=4801, + serialized_start=4990, + serialized_end=5075, ) @@ -2337,8 +2473,8 @@ _CREATE_LISTENER_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=4804, - serialized_end=5069, + serialized_start=5078, + serialized_end=5343, ) @@ -2419,8 +2555,8 @@ _DELETE_LISTENER_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=5072, - serialized_end=5253, + serialized_start=5346, + serialized_end=5527, ) @@ -2451,8 +2587,8 @@ _LIST_LISTENERS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5255, - serialized_end=5294, + serialized_start=5529, + serialized_end=5568, ) @@ -2500,8 +2636,8 @@ _LIST_SUBSYSTEMS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=5296, - serialized_end=5409, + serialized_start=5570, + serialized_end=5683, ) @@ -2525,8 +2661,8 @@ _GET_SUBSYSTEMS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5411, - serialized_end=5431, + serialized_start=5685, + serialized_end=5705, ) @@ -2562,8 +2698,8 @@ _GET_SPDK_NVMF_LOG_FLAGS_AND_LEVEL_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=5433, - serialized_end=5518, + serialized_start=5707, + serialized_end=5792, ) @@ -2594,8 +2730,8 @@ _DISABLE_SPDK_NVMF_LOGS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5520, - serialized_end=5573, + serialized_start=5794, + serialized_end=5847, ) @@ -2650,8 +2786,8 @@ _SET_SPDK_NVMF_LOGS_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=5576, - serialized_end=5727, + serialized_start=5850, + serialized_end=6001, ) @@ -2687,8 +2823,8 @@ _GET_GATEWAY_INFO_REQ = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=5729, - serialized_end=5793, + serialized_start=6003, + serialized_end=6067, ) @@ -2712,8 +2848,8 @@ _GET_GATEWAY_LOG_LEVEL_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5795, - serialized_end=5822, + serialized_start=6069, + serialized_end=6096, ) @@ -2744,8 +2880,8 @@ _SET_GATEWAY_LOG_LEVEL_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5824, - serialized_end=5883, + serialized_start=6098, + serialized_end=6157, ) @@ -2776,8 +2912,8 @@ _SHOW_GATEWAY_LISTENERS_INFO_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5885, - serialized_end=5941, + serialized_start=6159, + serialized_end=6215, ) @@ -2801,8 +2937,8 @@ _GET_GATEWAY_STATS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5943, - serialized_end=5966, + serialized_start=6217, + serialized_end=6240, ) @@ -2826,8 +2962,8 @@ _GET_THREAD_STATS_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5968, - serialized_end=5990, + serialized_start=6242, + serialized_end=6264, ) @@ -2858,8 +2994,8 @@ _SET_GATEWAY_IO_STATS_MODE_REQ = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=5992, - serialized_end=6040, + serialized_start=6266, + serialized_end=6314, ) @@ -2897,8 +3033,8 @@ _ANA_GROUP_STATE = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6042, - serialized_end=6102, + serialized_start=6316, + serialized_end=6376, ) @@ -2936,8 +3072,8 @@ _NQN_ANA_STATES = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6104, - serialized_end=6167, + serialized_start=6378, + serialized_end=6441, ) @@ -2968,8 +3104,8 @@ _ANA_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6169, - serialized_end=6212, + serialized_start=6443, + serialized_end=6486, ) @@ -3007,8 +3143,8 @@ _REQ_STATUS = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6214, - serialized_end=6265, + serialized_start=6488, + serialized_end=6539, ) @@ -3053,8 +3189,8 @@ _SUBSYS_STATUS = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6267, - serialized_end=6334, + serialized_start=6541, + serialized_end=6608, ) @@ -3099,8 +3235,8 @@ _NSID_STATUS = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6336, - serialized_end=6402, + serialized_start=6610, + serialized_end=6676, ) @@ -3131,8 +3267,8 @@ _SUBSYSTEMS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=6404, - serialized_end=6453, + serialized_start=6678, + serialized_end=6727, ) @@ -3277,8 +3413,8 @@ _SUBSYSTEM = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=6456, - serialized_end=6906, + serialized_start=6730, + serialized_end=7180, ) @@ -3354,8 +3490,8 @@ _LISTEN_ADDRESS = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=6909, - serialized_end=7060, + serialized_start=7183, + serialized_end=7334, ) @@ -3430,6 +3566,13 @@ _NAMESPACE = _descriptor.Descriptor( message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='rados_namespace_name', full_name='namespace.rados_namespace_name', index=9, + number=10, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -3471,9 +3614,14 @@ _NAMESPACE = _descriptor.Descriptor( index=5, containing_type=None, create_key=_descriptor._internal_create_key, fields=[]), + _descriptor.OneofDescriptor( + name='_rados_namespace_name', full_name='namespace._rados_namespace_name', + index=6, containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[]), ], - serialized_start=7063, - serialized_end=7323, + serialized_start=7337, + serialized_end=7657, ) @@ -3518,8 +3666,8 @@ _SUBSYSTEMS_INFO_CLI = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=7325, - serialized_end=7421, + serialized_start=7659, + serialized_end=7755, ) @@ -3649,8 +3797,8 @@ _SUBSYSTEM_CLI = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=7424, - serialized_end=7798, + serialized_start=7758, + serialized_end=8132, ) @@ -3859,8 +4007,8 @@ _GATEWAY_INFO = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=7801, - serialized_end=8500, + serialized_start=8135, + serialized_end=8834, ) @@ -3905,8 +4053,8 @@ _CLI_VERSION = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=8502, - serialized_end=8571, + serialized_start=8836, + serialized_end=8905, ) @@ -3951,8 +4099,8 @@ _GW_VERSION = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=8573, - serialized_end=8641, + serialized_start=8907, + serialized_end=8975, ) @@ -3983,8 +4131,8 @@ _POLL_GROUP_TRANSPORT_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=8643, - serialized_end=8686, + serialized_start=8977, + serialized_end=9020, ) @@ -4064,8 +4212,8 @@ _POLL_GROUP_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=8689, - serialized_end=8918, + serialized_start=9023, + serialized_end=9252, ) @@ -4117,8 +4265,8 @@ _GATEWAY_STATS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=8920, - serialized_end=9037, + serialized_start=9254, + serialized_end=9371, ) @@ -4170,8 +4318,8 @@ _THREAD_STATS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=9039, - serialized_end=9152, + serialized_start=9373, + serialized_end=9486, ) @@ -4216,8 +4364,8 @@ _SPDK_THREAD_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=9154, - serialized_end=9214, + serialized_start=9488, + serialized_end=9548, ) @@ -4312,8 +4460,8 @@ _LISTENER_INFO = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=9217, - serialized_end=9428, + serialized_start=9551, + serialized_end=9762, ) @@ -4358,8 +4506,8 @@ _LISTENERS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=9430, - serialized_end=9520, + serialized_start=9764, + serialized_end=9854, ) @@ -4397,8 +4545,8 @@ _GATEWAY_LISTENER_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=9522, - serialized_end=9616, + serialized_start=9856, + serialized_end=9950, ) @@ -4443,8 +4591,8 @@ _GATEWAY_LISTENERS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=9618, - serialized_end=9727, + serialized_start=9952, + serialized_end=10061, ) @@ -4523,8 +4671,8 @@ _HOST = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=9730, - serialized_end=10013, + serialized_start=10064, + serialized_end=10347, ) @@ -4569,8 +4717,8 @@ _LATENCY_STATS = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=10015, - serialized_end=10070, + serialized_start=10349, + serialized_end=10404, ) @@ -4629,8 +4777,8 @@ _LATENCY_GROUP = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=10073, - serialized_end=10225, + serialized_start=10407, + serialized_end=10559, ) @@ -4675,8 +4823,8 @@ _BUCKET_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=10227, - serialized_end=10315, + serialized_start=10561, + serialized_end=10649, ) @@ -4747,8 +4895,8 @@ _CONNECTION_IO_STATISTICS = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=10318, - serialized_end=10501, + serialized_start=10652, + serialized_end=10835, ) @@ -4807,8 +4955,8 @@ _HOSTS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=10503, - serialized_end=10623, + serialized_start=10837, + serialized_end=10957, ) @@ -4918,6 +5066,13 @@ _CONNECTION = _descriptor.Descriptor( message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='host_deleted', full_name='connection.host_deleted', index=14, + number=15, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -4959,9 +5114,14 @@ _CONNECTION = _descriptor.Descriptor( index=5, containing_type=None, create_key=_descriptor._internal_create_key, fields=[]), + _descriptor.OneofDescriptor( + name='_host_deleted', full_name='connection._host_deleted', + index=6, containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[]), ], - serialized_start=10626, - serialized_end=11130, + serialized_start=10960, + serialized_end=11508, ) @@ -5013,8 +5173,8 @@ _CONNECTIONS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=11132, - serialized_end=11246, + serialized_start=11510, + serialized_end=11624, ) @@ -5275,8 +5435,8 @@ _NAMESPACE_CLI = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=11249, - serialized_end=12233, + serialized_start=11627, + serialized_end=12611, ) @@ -5328,8 +5488,8 @@ _NAMESPACES_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=12235, - serialized_end=12350, + serialized_start=12613, + serialized_end=12728, ) @@ -5367,8 +5527,8 @@ _NAMESPACE_IO_ERROR = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=12352, - serialized_end=12401, + serialized_start=12730, + serialized_end=12779, ) @@ -5427,8 +5587,8 @@ _LIST_NAMESPACES_IO_STATS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=12404, - serialized_end=12549, + serialized_start=12782, + serialized_end=12927, ) @@ -5606,8 +5766,8 @@ _BDEV_IO_STATS_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=12552, - serialized_end=13183, + serialized_start=12930, + serialized_end=13561, ) @@ -5825,8 +5985,8 @@ _NAMESPACE_IO_STATS_INFO = _descriptor.Descriptor( create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=13186, - serialized_end=13916, + serialized_start=13564, + serialized_end=14294, ) @@ -5864,8 +6024,8 @@ _SPDK_LOG_FLAG_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=13918, - serialized_end=13969, + serialized_start=14296, + serialized_end=14347, ) @@ -5924,8 +6084,8 @@ _SPDK_NVMF_LOG_FLAGS_AND_LEVEL_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=13972, - serialized_end=14158, + serialized_start=14350, + serialized_end=14536, ) @@ -5970,8 +6130,8 @@ _GATEWAY_LOG_LEVEL_INFO = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=14160, - serialized_end=14255, + serialized_start=14538, + serialized_end=14633, ) @@ -6009,8 +6169,8 @@ _ENCRYPTION_ENTRY = _descriptor.Descriptor( extension_ranges=[], oneofs=[ ], - serialized_start=14257, - serialized_end=14326, + serialized_start=14635, + serialized_end=14704, ) _NAMESPACE_ADD_REQ.fields_by_name['encryption_entries'].message_type = _ENCRYPTION_ENTRY @@ -6183,6 +6343,9 @@ _GET_CONNECTION_IO_STATISTICS_REQ.fields_by_name['reset'].containing_oneof = _GE _REMOVE_HOST_REQ.oneofs_by_name['_force'].fields.append( _REMOVE_HOST_REQ.fields_by_name['force']) _REMOVE_HOST_REQ.fields_by_name['force'].containing_oneof = _REMOVE_HOST_REQ.oneofs_by_name['_force'] +_REMOVE_HOST_REQ.oneofs_by_name['_keep_connections'].fields.append( + _REMOVE_HOST_REQ.fields_by_name['keep_connections']) +_REMOVE_HOST_REQ.fields_by_name['keep_connections'].containing_oneof = _REMOVE_HOST_REQ.oneofs_by_name['_keep_connections'] _LIST_HOSTS_REQ.oneofs_by_name['_clear_alerts'].fields.append( _LIST_HOSTS_REQ.fields_by_name['clear_alerts']) _LIST_HOSTS_REQ.fields_by_name['clear_alerts'].containing_oneof = _LIST_HOSTS_REQ.oneofs_by_name['_clear_alerts'] @@ -6285,6 +6448,9 @@ _NAMESPACE.fields_by_name['nonce'].containing_oneof = _NAMESPACE.oneofs_by_name[ _NAMESPACE.oneofs_by_name['_auto_visible'].fields.append( _NAMESPACE.fields_by_name['auto_visible']) _NAMESPACE.fields_by_name['auto_visible'].containing_oneof = _NAMESPACE.oneofs_by_name['_auto_visible'] +_NAMESPACE.oneofs_by_name['_rados_namespace_name'].fields.append( + _NAMESPACE.fields_by_name['rados_namespace_name']) +_NAMESPACE.fields_by_name['rados_namespace_name'].containing_oneof = _NAMESPACE.oneofs_by_name['_rados_namespace_name'] _SUBSYSTEMS_INFO_CLI.fields_by_name['subsystems'].message_type = _SUBSYSTEM_CLI _SUBSYSTEM_CLI.oneofs_by_name['_has_dhchap_key'].fields.append( _SUBSYSTEM_CLI.fields_by_name['has_dhchap_key']) @@ -6383,6 +6549,9 @@ _CONNECTION.fields_by_name['disconnected_due_to_keepalive_timeout'].containing_o _CONNECTION.oneofs_by_name['_dhchap_controller_origin'].fields.append( _CONNECTION.fields_by_name['dhchap_controller_origin']) _CONNECTION.fields_by_name['dhchap_controller_origin'].containing_oneof = _CONNECTION.oneofs_by_name['_dhchap_controller_origin'] +_CONNECTION.oneofs_by_name['_host_deleted'].fields.append( + _CONNECTION.fields_by_name['host_deleted']) +_CONNECTION.fields_by_name['host_deleted'].containing_oneof = _CONNECTION.oneofs_by_name['_host_deleted'] _CONNECTIONS_INFO.fields_by_name['connections'].message_type = _CONNECTION _NAMESPACE_CLI.fields_by_name['encryption_algorithm'].enum_type = _ENCRYPTIONALGORITHM _NAMESPACE_CLI.fields_by_name['encryption_entries'].message_type = _ENCRYPTION_ENTRY @@ -6449,6 +6618,8 @@ DESCRIPTOR.message_types_by_name['delete_subsystem_req'] = _DELETE_SUBSYSTEM_REQ DESCRIPTOR.message_types_by_name['change_subsystem_key_req'] = _CHANGE_SUBSYSTEM_KEY_REQ DESCRIPTOR.message_types_by_name['add_subsystem_network_req'] = _ADD_SUBSYSTEM_NETWORK_REQ DESCRIPTOR.message_types_by_name['del_subsystem_network_req'] = _DEL_SUBSYSTEM_NETWORK_REQ +DESCRIPTOR.message_types_by_name['gw_refresh_network_req'] = _GW_REFRESH_NETWORK_REQ +DESCRIPTOR.message_types_by_name['gw_refresh_network_status'] = _GW_REFRESH_NETWORK_STATUS DESCRIPTOR.message_types_by_name['kmip_server_endpoint'] = _KMIP_SERVER_ENDPOINT DESCRIPTOR.message_types_by_name['kmip_server_endpoint_cli'] = _KMIP_SERVER_ENDPOINT_CLI DESCRIPTOR.message_types_by_name['add_kmip_server_endpoints_req'] = _ADD_KMIP_SERVER_ENDPOINTS_REQ @@ -6460,6 +6631,7 @@ DESCRIPTOR.message_types_by_name['add_host_req'] = _ADD_HOST_REQ DESCRIPTOR.message_types_by_name['change_host_key_req'] = _CHANGE_HOST_KEY_REQ DESCRIPTOR.message_types_by_name['get_connection_io_statistics_req'] = _GET_CONNECTION_IO_STATISTICS_REQ DESCRIPTOR.message_types_by_name['remove_host_req'] = _REMOVE_HOST_REQ +DESCRIPTOR.message_types_by_name['set_keep_host_connected_req'] = _SET_KEEP_HOST_CONNECTED_REQ DESCRIPTOR.message_types_by_name['list_hosts_req'] = _LIST_HOSTS_REQ DESCRIPTOR.message_types_by_name['list_connections_req'] = _LIST_CONNECTIONS_REQ DESCRIPTOR.message_types_by_name['create_listener_req'] = _CREATE_LISTENER_REQ @@ -6654,6 +6826,20 @@ del_subsystem_network_req = _reflection.GeneratedProtocolMessageType('del_subsys }) _sym_db.RegisterMessage(del_subsystem_network_req) +gw_refresh_network_req = _reflection.GeneratedProtocolMessageType('gw_refresh_network_req', (_message.Message,), { + 'DESCRIPTOR' : _GW_REFRESH_NETWORK_REQ, + '__module__' : 'dashboard.services.proto.gateway_pb2' + # @@protoc_insertion_point(class_scope:gw_refresh_network_req) + }) +_sym_db.RegisterMessage(gw_refresh_network_req) + +gw_refresh_network_status = _reflection.GeneratedProtocolMessageType('gw_refresh_network_status', (_message.Message,), { + 'DESCRIPTOR' : _GW_REFRESH_NETWORK_STATUS, + '__module__' : 'dashboard.services.proto.gateway_pb2' + # @@protoc_insertion_point(class_scope:gw_refresh_network_status) + }) +_sym_db.RegisterMessage(gw_refresh_network_status) + kmip_server_endpoint = _reflection.GeneratedProtocolMessageType('kmip_server_endpoint', (_message.Message,), { 'DESCRIPTOR' : _KMIP_SERVER_ENDPOINT, '__module__' : 'dashboard.services.proto.gateway_pb2' @@ -6731,6 +6917,13 @@ remove_host_req = _reflection.GeneratedProtocolMessageType('remove_host_req', (_ }) _sym_db.RegisterMessage(remove_host_req) +set_keep_host_connected_req = _reflection.GeneratedProtocolMessageType('set_keep_host_connected_req', (_message.Message,), { + 'DESCRIPTOR' : _SET_KEEP_HOST_CONNECTED_REQ, + '__module__' : 'dashboard.services.proto.gateway_pb2' + # @@protoc_insertion_point(class_scope:set_keep_host_connected_req) + }) +_sym_db.RegisterMessage(set_keep_host_connected_req) + list_hosts_req = _reflection.GeneratedProtocolMessageType('list_hosts_req', (_message.Message,), { 'DESCRIPTOR' : _LIST_HOSTS_REQ, '__module__' : 'dashboard.services.proto.gateway_pb2' @@ -7154,8 +7347,8 @@ _GATEWAY = _descriptor.ServiceDescriptor( index=0, serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_start=14797, - serialized_end=17838, + serialized_start=15175, + serialized_end=18365, methods=[ _descriptor.MethodDescriptor( name='namespace_add', @@ -7217,10 +7410,20 @@ _GATEWAY = _descriptor.ServiceDescriptor( serialized_options=None, create_key=_descriptor._internal_create_key, ), + _descriptor.MethodDescriptor( + name='gw_refresh_network', + full_name='Gateway.gw_refresh_network', + index=6, + containing_service=None, + input_type=_GW_REFRESH_NETWORK_REQ, + output_type=_GW_REFRESH_NETWORK_STATUS, + serialized_options=None, + create_key=_descriptor._internal_create_key, + ), _descriptor.MethodDescriptor( name='add_kmip_server_endpoints', full_name='Gateway.add_kmip_server_endpoints', - index=6, + index=7, containing_service=None, input_type=_ADD_KMIP_SERVER_ENDPOINTS_REQ, output_type=_REQ_STATUS, @@ -7230,7 +7433,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='del_kmip_server_endpoints', full_name='Gateway.del_kmip_server_endpoints', - index=7, + index=8, containing_service=None, input_type=_DEL_KMIP_SERVER_ENDPOINTS_REQ, output_type=_REQ_STATUS, @@ -7240,7 +7443,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_kmip_server_endpoints', full_name='Gateway.list_kmip_server_endpoints', - index=8, + index=9, containing_service=None, input_type=_LIST_KMIP_SERVER_ENDPOINTS_REQ, output_type=_KMIP_SERVER_ENDPOINTS_INFO, @@ -7250,7 +7453,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_namespaces', full_name='Gateway.list_namespaces', - index=9, + index=10, containing_service=None, input_type=_LIST_NAMESPACES_REQ, output_type=_NAMESPACES_INFO, @@ -7260,7 +7463,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_resize', full_name='Gateway.namespace_resize', - index=10, + index=11, containing_service=None, input_type=_NAMESPACE_RESIZE_REQ, output_type=_REQ_STATUS, @@ -7270,7 +7473,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_get_io_stats', full_name='Gateway.namespace_get_io_stats', - index=11, + index=12, containing_service=None, input_type=_NAMESPACE_GET_IO_STATS_REQ, output_type=_NAMESPACE_IO_STATS_INFO, @@ -7280,7 +7483,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_namespaces_io_stats', full_name='Gateway.list_namespaces_io_stats', - index=12, + index=13, containing_service=None, input_type=_LIST_NAMESPACES_IO_STATS_REQ, output_type=_LIST_NAMESPACES_IO_STATS_INFO, @@ -7290,7 +7493,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_set_qos_limits', full_name='Gateway.namespace_set_qos_limits', - index=13, + index=14, containing_service=None, input_type=_NAMESPACE_SET_QOS_REQ, output_type=_REQ_STATUS, @@ -7300,7 +7503,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_change_load_balancing_group', full_name='Gateway.namespace_change_load_balancing_group', - index=14, + index=15, containing_service=None, input_type=_NAMESPACE_CHANGE_LOAD_BALANCING_GROUP_REQ, output_type=_REQ_STATUS, @@ -7310,7 +7513,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_change_visibility', full_name='Gateway.namespace_change_visibility', - index=15, + index=16, containing_service=None, input_type=_NAMESPACE_CHANGE_VISIBILITY_REQ, output_type=_REQ_STATUS, @@ -7320,7 +7523,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_change_location', full_name='Gateway.namespace_change_location', - index=16, + index=17, containing_service=None, input_type=_NAMESPACE_CHANGE_LOCATION_REQ, output_type=_REQ_STATUS, @@ -7330,7 +7533,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_set_rbd_trash_image', full_name='Gateway.namespace_set_rbd_trash_image', - index=17, + index=18, containing_service=None, input_type=_NAMESPACE_SET_RBD_TRASH_IMAGE_REQ, output_type=_REQ_STATUS, @@ -7340,7 +7543,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_set_auto_resize', full_name='Gateway.namespace_set_auto_resize', - index=18, + index=19, containing_service=None, input_type=_NAMESPACE_SET_AUTO_RESIZE_REQ, output_type=_REQ_STATUS, @@ -7350,7 +7553,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_delete', full_name='Gateway.namespace_delete', - index=19, + index=20, containing_service=None, input_type=_NAMESPACE_DELETE_REQ, output_type=_REQ_STATUS, @@ -7360,7 +7563,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_add_host', full_name='Gateway.namespace_add_host', - index=20, + index=21, containing_service=None, input_type=_NAMESPACE_ADD_HOST_REQ, output_type=_REQ_STATUS, @@ -7370,7 +7573,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='namespace_delete_host', full_name='Gateway.namespace_delete_host', - index=21, + index=22, containing_service=None, input_type=_NAMESPACE_DELETE_HOST_REQ, output_type=_REQ_STATUS, @@ -7380,7 +7583,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='add_host', full_name='Gateway.add_host', - index=22, + index=23, containing_service=None, input_type=_ADD_HOST_REQ, output_type=_REQ_STATUS, @@ -7390,17 +7593,27 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='remove_host', full_name='Gateway.remove_host', - index=23, + index=24, containing_service=None, input_type=_REMOVE_HOST_REQ, output_type=_REQ_STATUS, serialized_options=None, create_key=_descriptor._internal_create_key, ), + _descriptor.MethodDescriptor( + name='set_keep_host_connected', + full_name='Gateway.set_keep_host_connected', + index=25, + containing_service=None, + input_type=_SET_KEEP_HOST_CONNECTED_REQ, + output_type=_REQ_STATUS, + serialized_options=None, + create_key=_descriptor._internal_create_key, + ), _descriptor.MethodDescriptor( name='change_host_key', full_name='Gateway.change_host_key', - index=24, + index=26, containing_service=None, input_type=_CHANGE_HOST_KEY_REQ, output_type=_REQ_STATUS, @@ -7410,7 +7623,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_hosts', full_name='Gateway.list_hosts', - index=25, + index=27, containing_service=None, input_type=_LIST_HOSTS_REQ, output_type=_HOSTS_INFO, @@ -7420,7 +7633,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_connections', full_name='Gateway.list_connections', - index=26, + index=28, containing_service=None, input_type=_LIST_CONNECTIONS_REQ, output_type=_CONNECTIONS_INFO, @@ -7430,7 +7643,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_connection_io_statistics', full_name='Gateway.get_connection_io_statistics', - index=27, + index=29, containing_service=None, input_type=_GET_CONNECTION_IO_STATISTICS_REQ, output_type=_CONNECTION_IO_STATISTICS, @@ -7440,7 +7653,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='create_listener', full_name='Gateway.create_listener', - index=28, + index=30, containing_service=None, input_type=_CREATE_LISTENER_REQ, output_type=_REQ_STATUS, @@ -7450,7 +7663,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='delete_listener', full_name='Gateway.delete_listener', - index=29, + index=31, containing_service=None, input_type=_DELETE_LISTENER_REQ, output_type=_REQ_STATUS, @@ -7460,7 +7673,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_listeners', full_name='Gateway.list_listeners', - index=30, + index=32, containing_service=None, input_type=_LIST_LISTENERS_REQ, output_type=_LISTENERS_INFO, @@ -7470,7 +7683,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='list_subsystems', full_name='Gateway.list_subsystems', - index=31, + index=33, containing_service=None, input_type=_LIST_SUBSYSTEMS_REQ, output_type=_SUBSYSTEMS_INFO_CLI, @@ -7480,7 +7693,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_subsystems', full_name='Gateway.get_subsystems', - index=32, + index=34, containing_service=None, input_type=_GET_SUBSYSTEMS_REQ, output_type=_SUBSYSTEMS_INFO, @@ -7490,7 +7703,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='set_ana_state', full_name='Gateway.set_ana_state', - index=33, + index=35, containing_service=None, input_type=_ANA_INFO, output_type=_REQ_STATUS, @@ -7500,7 +7713,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_spdk_nvmf_log_flags_and_level', full_name='Gateway.get_spdk_nvmf_log_flags_and_level', - index=34, + index=36, containing_service=None, input_type=_GET_SPDK_NVMF_LOG_FLAGS_AND_LEVEL_REQ, output_type=_SPDK_NVMF_LOG_FLAGS_AND_LEVEL_INFO, @@ -7510,7 +7723,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='disable_spdk_nvmf_logs', full_name='Gateway.disable_spdk_nvmf_logs', - index=35, + index=37, containing_service=None, input_type=_DISABLE_SPDK_NVMF_LOGS_REQ, output_type=_REQ_STATUS, @@ -7520,7 +7733,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='set_spdk_nvmf_logs', full_name='Gateway.set_spdk_nvmf_logs', - index=36, + index=38, containing_service=None, input_type=_SET_SPDK_NVMF_LOGS_REQ, output_type=_REQ_STATUS, @@ -7530,7 +7743,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_gateway_info', full_name='Gateway.get_gateway_info', - index=37, + index=39, containing_service=None, input_type=_GET_GATEWAY_INFO_REQ, output_type=_GATEWAY_INFO, @@ -7540,7 +7753,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_gateway_log_level', full_name='Gateway.get_gateway_log_level', - index=38, + index=40, containing_service=None, input_type=_GET_GATEWAY_LOG_LEVEL_REQ, output_type=_GATEWAY_LOG_LEVEL_INFO, @@ -7550,7 +7763,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='set_gateway_log_level', full_name='Gateway.set_gateway_log_level', - index=39, + index=41, containing_service=None, input_type=_SET_GATEWAY_LOG_LEVEL_REQ, output_type=_REQ_STATUS, @@ -7560,7 +7773,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='show_gateway_listeners_info', full_name='Gateway.show_gateway_listeners_info', - index=40, + index=42, containing_service=None, input_type=_SHOW_GATEWAY_LISTENERS_INFO_REQ, output_type=_GATEWAY_LISTENERS_INFO, @@ -7570,7 +7783,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_gateway_stats', full_name='Gateway.get_gateway_stats', - index=41, + index=43, containing_service=None, input_type=_GET_GATEWAY_STATS_REQ, output_type=_GATEWAY_STATS_INFO, @@ -7580,7 +7793,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='get_thread_stats', full_name='Gateway.get_thread_stats', - index=42, + index=44, containing_service=None, input_type=_GET_THREAD_STATS_REQ, output_type=_THREAD_STATS_INFO, @@ -7590,7 +7803,7 @@ _GATEWAY = _descriptor.ServiceDescriptor( _descriptor.MethodDescriptor( name='set_gateway_io_stats_mode', full_name='Gateway.set_gateway_io_stats_mode', - index=43, + index=45, containing_service=None, input_type=_SET_GATEWAY_IO_STATS_MODE_REQ, output_type=_REQ_STATUS, diff --git a/src/pybind/mgr/dashboard/services/proto/gateway_pb2_grpc.py b/src/pybind/mgr/dashboard/services/proto/gateway_pb2_grpc.py index 338d542d3cf..99c60c5cab4 100644 --- a/src/pybind/mgr/dashboard/services/proto/gateway_pb2_grpc.py +++ b/src/pybind/mgr/dashboard/services/proto/gateway_pb2_grpc.py @@ -44,6 +44,11 @@ class GatewayStub(object): request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.del_subsystem_network_req.SerializeToString, response_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.FromString, ) + self.gw_refresh_network = channel.unary_unary( + '/Gateway/gw_refresh_network', + request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_req.SerializeToString, + response_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_status.FromString, + ) self.add_kmip_server_endpoints = channel.unary_unary( '/Gateway/add_kmip_server_endpoints', request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.add_kmip_server_endpoints_req.SerializeToString, @@ -134,6 +139,11 @@ class GatewayStub(object): request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.remove_host_req.SerializeToString, response_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.FromString, ) + self.set_keep_host_connected = channel.unary_unary( + '/Gateway/set_keep_host_connected', + request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.set_keep_host_connected_req.SerializeToString, + response_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.FromString, + ) self.change_host_key = channel.unary_unary( '/Gateway/change_host_key', request_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.change_host_key_req.SerializeToString, @@ -281,6 +291,13 @@ class GatewayServicer(object): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def gw_refresh_network(self, request, context): + """Refresh auto-listeners for all network masks configured on the given subsystem on this gateway + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_kmip_server_endpoints(self, request, context): """Add KMIP server endpoints """ @@ -407,6 +424,13 @@ class GatewayServicer(object): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def set_keep_host_connected(self, request, context): + """Set keep host connected flag + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def change_host_key(self, request, context): """Changes a host inband authentication keys """ @@ -580,6 +604,11 @@ def add_GatewayServicer_to_server(servicer, server): request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.del_subsystem_network_req.FromString, response_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.SerializeToString, ), + 'gw_refresh_network': grpc.unary_unary_rpc_method_handler( + servicer.gw_refresh_network, + request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_req.FromString, + response_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_status.SerializeToString, + ), 'add_kmip_server_endpoints': grpc.unary_unary_rpc_method_handler( servicer.add_kmip_server_endpoints, request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.add_kmip_server_endpoints_req.FromString, @@ -670,6 +699,11 @@ def add_GatewayServicer_to_server(servicer, server): request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.remove_host_req.FromString, response_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.SerializeToString, ), + 'set_keep_host_connected': grpc.unary_unary_rpc_method_handler( + servicer.set_keep_host_connected, + request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.set_keep_host_connected_req.FromString, + response_serializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.SerializeToString, + ), 'change_host_key': grpc.unary_unary_rpc_method_handler( servicer.change_host_key, request_deserializer=dashboard_dot_services_dot_proto_dot_gateway__pb2.change_host_key_req.FromString, @@ -882,6 +916,23 @@ class Gateway(object): options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + @staticmethod + def gw_refresh_network(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/Gateway/gw_refresh_network', + dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_req.SerializeToString, + dashboard_dot_services_dot_proto_dot_gateway__pb2.gw_refresh_network_status.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + @staticmethod def add_kmip_server_endpoints(request, target, @@ -1188,6 +1239,23 @@ class Gateway(object): options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + @staticmethod + def set_keep_host_connected(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/Gateway/set_keep_host_connected', + dashboard_dot_services_dot_proto_dot_gateway__pb2.set_keep_host_connected_req.SerializeToString, + dashboard_dot_services_dot_proto_dot_gateway__pb2.req_status.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + @staticmethod def change_host_key(request, target, From 937ffe0d03efa135cf08d3a4f90f07dacf3449ca Mon Sep 17 00:00:00 2001 From: David Galloway Date: Mon, 15 Jun 2026 17:29:22 -0400 Subject: [PATCH 234/596] doc: Document restarting failed release builds Signed-off-by: David Galloway --- doc/dev/release-process.rst | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/doc/dev/release-process.rst b/doc/dev/release-process.rst index 7a5781b0e99..6bb5c2c5f5f 100644 --- a/doc/dev/release-process.rst +++ b/doc/dev/release-process.rst @@ -87,7 +87,7 @@ Once QE has determined a stopping point in the working (e.g., ``squid``) branch, Notify the "Build Lead" that the release branch is ready. -2. Starting the build +2a. Starting the build ===================== We'll use a stable/regular 19.2.2 release of Squid as an example throughout this document. @@ -123,6 +123,37 @@ NOTE: if for some reason the build has to be restarted (for example if one distr 5. Click ``Build``. +2b. What to do if your build fails +================================== + +The ceph-release-pipeline parent job has three stages. + +If your build fails during the "create ceph release tag" stage, troubleshoot the child ceph-tag job and re-run the parent ceph-release-pipeline job with the same parameters. + +---- + +If your build fails during the "package build" stage, troubleshoot the child ceph-dev-pipeline job. If only one variant failed to build (e.g., centos9 arm64), start a new ceph-release-pipeline job specifying:: + + DISTROS=centos9 + ARCHS=arm64 + TAG=false <-- VERY IMPORTANT + +This will leave the version commit and previously-created tag intact in ceph-releases.git. You will want the subsequent ceph-dev-pipeline job to reuse that SHA/tag. + +Once all of your variants are successfully built, you will have to manually run the ceph-tag job. For example,:: + + BRANCH=tentacle + TAG=true + TAG_PHASE=push + VERSION=20.2.0 + RELEASE_TYPE=STABLE + +Then proceed with the normal release process. + +---- + +If your build fails during the "push ceph release tag" stage, troubleshoot the child ceph-tag job and re-run **just** the ceph-tag job again manually. Do not re-run ceph-release-pipeline. + 3. Release Notes ================ From c72f9a86b8833e3b2d159e1411ef68fbfbe8ed5c Mon Sep 17 00:00:00 2001 From: "Kamoltat (Junior) Sirivadhna" Date: Tue, 2 Jun 2026 11:43:14 +0000 Subject: [PATCH 235/596] mon: fix ConnectionTracker::notify_rank_removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: When multiple monitors are removed from the monmap in a single update, the iterative rank adjustment in notify_rank_removed() fails to maintain the invariant that our local rank matches the monmap's new_rank until all iterations complete. Problem: The old code decremented rank for each removed rank: ``` for (auto i = monmap->removed_ranks.rbegin(); ...) { int remove_rank = *i; int new_rank = monmap->get_rank(messenger->get_myaddrs()); // Old logic (in ConnectionTracker): if (remove_rank < rank) { --rank; } // This assert would fail on first iteration when removed_ranks.size() > 1 ceph_assert(rank == new_rank); } ``` When removed_ranks = {0, 1} and we are mon.d (originally rank 3): - new_rank from monmap = 1 (final correct value after removing 0 and 1) Iteration 1 (remove rank 1): Before: rank = 3 Execute: rank_removed=1 < 3, so --rank -> rank = 2 Check: rank == new_rank? -> 2 == 1? FAIL Iteration 2 (remove rank 0): Before: rank = 2 Execute: rank_removed=0 < 2, so --rank -> rank = 1 Check: rank == new_rank? → 1 == 1? PASS The invariant is violated during intermediate iterations because each --rank adjustment only accounts for ONE removed rank, but new_rank from monmap already accounts for ALL removed ranks. Solution: Trust the caller's new_rank from monmap instead of trying to compute it iteratively. The monmap has already recalculated ranks correctly by removing all monitors atomically, so we should just use that value. Changed: ``` if (rank_removed < rank) { --rank; } ``` To: // Trust the caller's new_rank from the monmap rather than trying to compute it. // This handles cases where multiple ranks are removed simultaneously. rank = new_rank; Now the invariant holds on every iteration regardless of how many ranks are removed. Fixes: https://tracker.ceph.com/issues/77423 Signed-off-by: Kamoltat (Junior) Sirivadhna --- src/mon/ConnectionTracker.cc | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/mon/ConnectionTracker.cc b/src/mon/ConnectionTracker.cc index e78b5be3086..9cc05054357 100644 --- a/src/mon/ConnectionTracker.cc +++ b/src/mon/ConnectionTracker.cc @@ -261,18 +261,15 @@ void ConnectionTracker::notify_rank_removed(int rank_removed, int new_rank) ceph_assert((peer_reports.size() == starting_size) || (peer_reports.size() + 1 == starting_size)); - if (rank_removed < rank) { // if the rank removed is lower than us, we need to adjust. - --rank; - my_reports.rank = rank; // also adjust my_reports.rank. - } + // Trust the caller's new_rank from the monmap rather than trying to compute it. + // This handles cases where multiple ranks are removed simultaneously. + rank = new_rank; + my_reports.rank = new_rank; ldout(cct, 20) << "my rank after: " << rank << dendl; ldout(cct, 20) << "peer_reports after: " << peer_reports << dendl; ldout(cct, 20) << "my_reports after: " << my_reports << dendl; - //check if the new_rank from monmap is equal to our adjusted rank. - ceph_assert(rank == new_rank); - increase_version(); } From 8a2cbbd4f89bf3410935c82f17bb2a621034f9c3 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Mon, 15 Jun 2026 17:14:13 -0400 Subject: [PATCH 236/596] doc/dev/release-process: update according to supported releases From: https://docs.ceph.com/en/latest/start/os-recommendations/ Signed-off-by: Patrick Donnelly --- doc/dev/release-process.rst | 24 ++++++++++-------------- doc/start/os-recommendations.rst | 2 ++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/doc/dev/release-process.rst b/doc/dev/release-process.rst index 7a5781b0e99..d02ee49ea0b 100644 --- a/doc/dev/release-process.rst +++ b/doc/dev/release-process.rst @@ -104,21 +104,17 @@ We'll use a stable/regular 19.2.2 release of Squid as an example throughout this NOTE: if for some reason the build has to be restarted (for example if one distro failed) then the ``TAG`` option has to be unchecked. -4. Use https://docs.ceph.com/en/latest/start/os-recommendations/?highlight=debian#platforms to determine the ``DISTROS`` parameter. For example, +4. Use :ref:`start-platforms` to determine the ``DISTROS`` parameter. For example, - +-------------------+--------------------------------------------------+ - | Release | Distro Codemap | - +===================+==================================================+ - | pacific (16.X.X) | ``focal bionic buster bullseye`` | - +-------------------+--------------------------------------------------+ - | quincy (17.X.X) | ``jammy focal centos9 bullseye`` | - +-------------------+--------------------------------------------------+ - | reef (18.X.X) | ``jammy focal centos9 windows bookworm`` | - +-------------------+--------------------------------------------------+ - | squid (19.X.X) | ``jammy centos9 windows bookworm`` | - +-------------------+--------------------------------------------------+ - | tentacle (20.X.X) | ``jammy centos9 noble windows bookworm rocky10`` | - +-------------------+--------------------------------------------------+ + +-------------------+---------------------------------------------------------+ + | Release | Distro Codemap | + +===================+=========================================================+ + | squid (19.X.X) | ``jammy centos9 windows bookworm`` | + +-------------------+---------------------------------------------------------+ + | tentacle (20.X.X) | ``jammy centos9 rocky10 windows bookworm`` | + +-------------------+---------------------------------------------------------+ + | umbrella (21.X.X) | ``jammy noble centos9 rocky10 bookworm trixie windows`` | + +-------------------+---------------------------------------------------------+ 5. Click ``Build``. diff --git a/doc/start/os-recommendations.rst b/doc/start/os-recommendations.rst index da0589c08cf..6be7094b69f 100644 --- a/doc/start/os-recommendations.rst +++ b/doc/start/os-recommendations.rst @@ -54,6 +54,8 @@ Linux Kernel full-time maintainer. As of July 2025 there are no plans to remove this client but the future is uncertain. +.. _start-platforms: + Platforms ========= From 35447386bac00ae80758a9ba9ecc2b146c2b699f Mon Sep 17 00:00:00 2001 From: Yuri Weinstein Date: Thu, 11 Jun 2026 10:09:25 -0700 Subject: [PATCH 237/596] doc: tentacle 20.2.2 release notes Resolves: https://tracker.ceph.com/issues/77357 Signed-off-by: Yuri Weinstein --- doc/releases/index.rst | 1 + doc/releases/releases.yml | 2 + doc/releases/tentacle.rst | 255 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) diff --git a/doc/releases/index.rst b/doc/releases/index.rst index 6d7e201d284..502bdcbaf45 100644 --- a/doc/releases/index.rst +++ b/doc/releases/index.rst @@ -67,6 +67,7 @@ Release timeline .. _Tentacle: tentacle .. _20.2.0: tentacle#v20-2-0-tentacle .. _20.2.1: tentacle#v20-2-1-tentacle +.. _20.2.2: tentacle#v20-2-2-tentacle .. _Squid: squid .. _19.2.0: squid#v19-2-0-squid diff --git a/doc/releases/releases.yml b/doc/releases/releases.yml index 8f72917bec1..b3772b88dbc 100644 --- a/doc/releases/releases.yml +++ b/doc/releases/releases.yml @@ -19,6 +19,8 @@ releases: tentacle: target_eol: 2027-11-18 releases: + - version: 20.2.2 + released: 2026-06-15 - version: 20.2.1 released: 2026-04-06 - version: 20.2.0 diff --git a/doc/releases/tentacle.rst b/doc/releases/tentacle.rst index 7b020e88fd5..1de8b03c012 100644 --- a/doc/releases/tentacle.rst +++ b/doc/releases/tentacle.rst @@ -4,6 +4,261 @@ Tentacle Tentacle is the 20th stable release of Ceph. +v20.2.2 Tentacle +================ + +This is the second minor release in the Tentacle series. We recommend that all users update to this release. + +Release Date +------------ + +June 15, 2026 + +Notable Changes +--------------- + +* Rocky 10 is now supported starting with v20.2.2. Please see the `supported platforms `_ for current and planned support in Ceph. + +MDS (Metadata Server) +--------------------- + +* Upgrade Testing Architecture: Split up existing upgrade sequences into distinct CentOS Stream 9 and Rocky Linux 10 automated test variants across multiple upgrade suites (including parallel, stress-split, and telemetry suites). +* Queueing Logic Update: Retry requests are now added directly to the MDSRank wait queue rather than going through the finisher. +* Optimization: Removed duplicate context completion calls and adjusted scan_stray_dir after refining the MDSContext class. +* Reversion: Reverted a prior change that moved MDSContext completion handling to the finish method. +* Segmentation fault fixed due to incorrect queueing of request retries. + +OSD (Object Storage Daemon) +--------------------------- + +* FastEC Activation: Always updates the pwlc epoch when activating. +* PGLog Missed List: Fixed a bug to ensure the correct version is attached to the missing list when ignoring log entries. +* Data Integrity Asserts: Added assertions to explicitly catch potential corruption in the OSD missing list. +* Data Structure Changes: Modified the rmissing map key type from version_t to eversion_t. +* Rollback & Vector Fixes: Corrected rollback logic for partial write object information (OI) and optimized Erasure Coding (EC) by ensuring Twiddle creates a full-sized vector. + +RGW (RADOS Gateway) +------------------- + +* Lifecycle Management: Fixed lifecycle transition issues affecting encrypted multipart objects. +* Multisite Concurrency: Improved concurrency handling when a value of 1 is provided by the caller, added logging for concurrency state transitions within adj_concurrency, exposed lock latency as a performance counter for data synchronization, and fixed an uninitialized LatencyMonitor average by shifting to an exponentially weighted moving average. +* REST & Query Handling: RESTArgs::get_string() now properly URL-decodes incoming query parameters. + +RADOS / librados / neorados +--------------------------- + +* Linger Operations: Rewrote safety checks to eliminate use-after-free vulnerabilities and LingerOp memory leaks when an unwatch operation returns ENOTCONN. Replaced the flawed is_valid_watch() check with a safe linger_by_cookie() lookup which safely manages LingerOp references using intrusive_ptr. Ensured librados linger callbacks hold a persistent reference to LingerOp to protect against races with simultaneous linger_cancel() requests. Configured librados::IoCtxImpl::aio_unwatch() to asynchronously deliver ENOTCONN to AioCompletion instead of returning the error directly. +* Watch/Notify: Fixed neorados notification queue bounds enforcement so that an overflow marker is only appended on the first message exceeding capacity, rather than duplicating it on every subsequent message. Prevented double-cleanup triggers in watch/notify operations when incoming errors arrive after maybe_cleanup() runs. Fixed a bug where notify would lose original error values by ensuring it no longer attempts to decode empty responses. Enhanced io_context shutdown procedures to clear handlers and route cleanly through linger_cancel to avoid use-after-free states. +* Async Utilities: Fixed an issue regarding the improper removal of objects from the async service list. + +Dashboard +----------------- + +* NVMeoF Revamp: Complete UI overhaul with DHCHAP controller key support, namespace encryption, and secure listeners configuration. +* Pools & RGW: Added stretch cluster validation for pools, fixed RGW restart/stop issues, storage class restrictions, sync policy fixes, and MSR EC Profile support. +* NFS: Toggle visibility for CephFS snapshots, fixed export creation and path value consistency issues + +ceph-volume +----------- + +* Inventory Scanning: Enhanced inventory discovery logic to automatically skip RAM disk devices (/dev/ram*). + +External Block Device (extblkdev) +--------------------------------- + +* Plugin Stability: Fixed an assertion failure in the FCM plugin when encountering multivolume devices. + +Changelog +--------- +* (tentacle) ceph-volume: backport PRs 67047 and 67240 (`pr#67343 `_, Guillaume Abrioux, Parfait Detchenou) +* 20.2.1 (`pr#68185 `_, Ceph Release Team) +* [tentacle] bluestore, extblkdev: Now plugins can raise health warnings (`pr#68663 `_, Adam Kupczyk, Igor Fedotov, Martin Ohmacht) +* Backporting PRs 67236 and 67419 (`pr#67533 `_, Adam King) +* Beacon diff + Stretched cluster (`pr#68347 `_, Leonid Chernin, Samuel Just) +* ceph-volume: include LVM mapper devices in get_devices() (`pr#67989 `_, Guillaume Abrioux) +* ceph-volume: skip /dev/ram\* devices in inventory (`pr#68552 `_, Ujjawal Anand) +* ceph-volume: skip mkfs discard for LVM NVMe OSDs (`pr#68286 `_, Ujjawal Anand) +* ceph-volume: skip redundant NVMe mkfs discards (`pr#67341 `_, Ujjawal Anand) +* ceph-volume: skip virtual cdrom devices in inventory (`pr#68108 `_, Ujjawal Anand) +* ceph.spec.in: replace golang github prometheus with promtool binary path (`pr#68420 `_, Nizamudeen A) +* ceph_mon: Fix shutdown order to destroy Monitor before closing mon store (`pr#68399 `_, Prashant D) +* cephadm: wait for latest osd map after ceph-volume before OSD deploy (`pr#68379 `_, Guillaume Abrioux) +* cephfs: MDCache request cleanup (`pr#66469 `_, Abhishek Lekshmanan) +* Check if `HTTP_X_AMZ_COPY_SOURCE` header is empty (`pr#66027 `_, Suyash Dongre) +* client: adjust `Fb` cap ref count check during synchronous fsync() (`issue#73624 `_, `pr#65913 `_, Venky Shankar) +* client: crash caused by invalid iterator in _readdir_cache_cb (`pr#65957 `_, Zhansong Gao) +* container/build.sh: add 'rocky-10' suffix if necessary (`pr#67895 `_, Dan Mick) +* container/build.sh: FROM_IMAGE=rockylinux-10 default for >=tentacle backports (`pr#67960 `_, Matan Breizman, David Galloway, John Mulligan, Dan Mick) +* debian: package mgr/smb in ceph-mgr-modules-core (`pr#67510 `_, Roland Sommer) +* debian: remove invoke-rc.d calls from postrm scripts (`pr#67354 `_, Kefu Chai) +* debian: remove stale distutils override from py3dist-overrides (`pr#68276 `_, Thomas Lamprecht, Max R. Carrara, Kefu Chai) +* doc: Batch backport for start/hardware-recommendations.rst (`pr#67907 `_, Anthony D'Atri, Marc Methot, Pierre Riteau, Ville Ojamo) +* extblkdev: Fix FCM plugin asserting on multivolume devices (`pr#68877 `_, Adam Kupczyk) +* found duplicate series for the match group {fs_id="-1"} (`pr#68369 `_, bst2002git) +* Implement Admin REST APIs for Setting Account Quota (`pr#66905 `_, Nicholas Liu, Jiffin Tony Thottan) +* kv/RocksDB: Add instrumentation to BinnedLRUCache (`pr#67349 `_, Adam Kupczyk) +* libcephsqlite: ensure atexit handlers are registered after openssl (`pr#68263 `_, Patrick Donnelly) +* librbd/cache/pwl: WriteLogOperationSet::cell can be garbage (`pr#67705 `_, Ilya Dryomov) +* librbd/migration/QCOWFormat: avoid use-after-free in execute_request() (`pr#68188 `_, Ilya Dryomov) +* librbd/mirror: detect trashed snapshots in UnlinkPeerRequest (`pr#67583 `_, Ilya Dryomov) +* librbd: avoid losing sparseness in read_parent() (`pr#68463 `_, Ilya Dryomov) +* librbd: don't complete ImageUpdateWatchers::shut_down() prematurely (`pr#67581 `_, Ilya Dryomov) +* librbd: rbd_aio_write_with_crc32c (`pr#68038 `_, Alexander Indenbaum) +* mds: add ref counting to LogSegment (`pr#68439 `_, Milind Changire) +* mds: add retry request to MDSRank wait queue rather via finisher (`issue#76031 `_, `pr#68905 `_, Venky Shankar) +* mds: scrub pins more inodes than the mds_cache_memory_limit (`pr#67808 `_, Md Mahamudur Rahaman Sajib) +* mds: use SimpleLock::WAIT_ALL for wait mask (`pr#68313 `_, Patrick Donnelly) +* mgr/cephadm: Add KMIP server support for NVMeoF gateway (`pr#68086 `_, Gil Bregman) +* mgr/cephadm: add rbd_with_crc32c parameter to nvmeof service spec (`pr#66933 `_, Alexander Indenbaum) +* mgr/cephadm: fix mgmt-gateway startup on IPv6 VIP (`pr#68387 `_, Kobi Ginon) +* mgr/cephadm: include cluster FSID in root CA Common Name (CN) (`pr#64692 `_, Redouane Kachach, Kushal Deb) +* mgr/cephadm: serialize OSD class before returning for OSD rm status (`pr#69226 `_, Adam King) +* mgr/DaemonServer: Implement ok-to-upgrade command (`pr#66948 `_, Sridhar Seshasayee) +* mgr/DaemonServer: Limit search for OSDs to upgrade within the crush bucket (`pr#68350 `_, Sridhar Seshasayee) +* mgr/dashboard : Add bottom padding for dashboard screens (`pr#68523 `_, Abhishek Desai) +* mgr/dashboard : add stretch cluster validation for pools form (`pr#68476 `_, Afreen Misbah, Abhishek Desai) +* mgr/dashboard : Fix RGW restart/stop issue (`pr#68554 `_, Abhishek Desai) +* mgr/dashboard : fix-non-versioning-bucket-issue (`pr#68520 `_, Abhishek Desai) +* mgr/dashboard : Fixes EC profile used pool empty (`pr#68730 `_, Abhishek Desai) +* mgr/dashboard : Restrict create storage class with existing name (`pr#68475 `_, Abhishek Desai) +* mgr/dashboard: [RGW-NFS]: User level export creation via UI fails with 500 - Internal Server Error (`pr#67013 `_, Dnyaneshwari Talwekar) +* mgr/dashboard: [snap-visibility]Edit Client config option remains stuck in loading when nfs user is configured (`pr#68542 `_, Dnyaneshwari Talwekar) +* mgr/dashboard: [storage-class]: Deleting local storage class from UI does not remove its entry from zone (`pr#67949 `_, Dnyaneshwari Talwekar) +* mgr/dashboard: Add DHCHAP controller key to NVME host commands (`pr#67600 `_, Gil Bregman) +* mgr/dashboard: add helper text to bucket form > policy and other spacing fixes (`pr#67871 `_, Naman Munet) +* mgr/dashboard: Add location to gateway info command in NVMeoF CLI (`pr#68345 `_, Gil Bregman) +* mgr/dashboard: Add namespace encryption support to NVMeoF CLI (`pr#68339 `_, Gil Bregman) +* mgr/dashboard: Add nvmeof_top_cli service (`pr#67566 `_, Vallari Agrawal) +* mgr/dashboard: Add option to edit zone with keys/ (`pr#68317 `_, Aashish Sharma) +* mgr/dashboard: Add option to set motd via api (`pr#68678 `_, Aashish Sharma) +* mgr/dashboard: Add overview page (`pr#67840 `_, Afreen Misbah, Devika Babrekar, Aashish Sharma, Abhishek Desai, Naman Munet, Nizamudeen A) +* mgr/dashboard: Add port and secure-listeners to subsystem add NVMeoF CLI command (`pr#68370 `_, Vallari Agrawal, Gil Bregman) +* mgr/dashboard: Add restore events in notification screen (`pr#67912 `_, pujashahu, pujaoshahu) +* mgr/dashboard: Add secure and verify-host-name to "listener add" on NVMeoF CLI (`pr#67799 `_, Gil Bregman) +* mgr/dashboard: Adding rados ns option into add_ns_req (`pr#67470 `_, gadi-didi) +* mgr/dashboard: Allow empty port value when adding a listener in NVMEoF CLI (`pr#68766 `_, Gil Bregman) +* mgr/dashboard: Batch backport nvmeof revamp (`pr#67839 `_, Afreen Misbah, Nizamudeen A, Sagar Gopale, pujaoshahu, Puja Shahu, Ville Ojamo) +* mgr/dashboard: Bump lodash (`pr#68695 `_, Afreen Misbah) +* mgr/dashboard: bump nvmeof submodule to 1.6.5 (`pr#67326 `_, Vallari Agrawal, Tomer Haskalovitch) +* mgr/dashboard: carbonize-osd-flags-modal (`pr#68133 `_, Afreen Misbah, Sagar Gopale) +* mgr/dashboard: Difference in "path" value observed when rgw user level export created via dashboard vs cli (`pr#68583 `_, Dnyaneshwari Talwekar) +* mgr/dashboard: fix subvolume group corruption from smb share form (`pr#68103 `_, Nizamudeen A) +* mgr/dashboard: Fix tags in subvolume list and subvolume groups list (`pr#68382 `_, pujaoshahu) +* mgr/dashboard: Making 'ISA' as default plugin for EC profiles created through dashboard (`pr#68373 `_, Devika Babrekar) +* mgr/dashboard: mgr/dashboard: Carbonize Realm Name and Token block in Multi-site Replication Wizard (`pr#68546 `_, Sagar Gopale) +* mgr/dashboard: Misleading warning when no eligible devices are available for OSD creation (`pr#67637 `_, Naman Munet) +* mgr/dashboard: nfs export creation fails with obj deserialization (`pr#67564 `_, Nizamudeen A) +* mgr/dashboard: NFS: Toggle visibility of CephFS snapshots (`pr#67636 `_, Afreen Misbah, Dnyaneshwari Talwekar) +* mgr/dashboard: Option to select archive option while Import Multi-site Token (`pr#68513 `_, Aashish Sharma) +* mgr/dashboard: remove sync_from entry when sync_from_all is true (`pr#68536 `_, Aashish Sharma) +* mgr/dashboard: Rename Alert breadcrumb to Alert Rules (`pr#68238 `_, Sagar Gopale) +* mgr/dashboard: sync policy created for a bucket in Object >> Multi-site >> Sync-policy, is not reflecting under bucket's replication (`pr#68512 `_, Naman Munet) +* mgr/dashboard:Adding MSR EC Profile via dashboard (`pr#68349 `_, Devika Babrekar) +* mgr/smb: fix error handling for fundamental resource parsing (`pr#65861 `_, John Mulligan) +* mgr/test_orchestrator: fixing daemon_action method signature (`pr#69231 `_, Redouane Kachach) +* mgr: add config to load modules in main interpreter instead of subinterpreter (`pr#67515 `_, Nitzan Mordhai, Nitzan Mordechai, Samuel Just) +* mgr: ensure that all modules have started before advertising active mgr (`pr#67850 `_, Laura Flores) +* mgr: fix continous smb MgrDBNotReady (`pr#68598 `_, Pedro Gonzalez Gomez) +* mgr: fix PyObject\* refcounting in TTLCache and cleanup logic (`pr#66482 `_, Nitzan Mordechai) +* mgr: isolated CherryPy to prevent global state sharing (`pr#67465 `_, Nizamudeen A, Anmol Babu) +* mon [stretch-mode]: Allow a max bucket weight diff threshold (`pr#67790 `_, Kamoltat Sirivadhna, Kamoltat (Junior) Sirivadhna) +* mon/AuthMonitor: add osd w cap for superuser client (`pr#68314 `_, Venky Shankar, Patrick Donnelly) +* monitoring: Fix application overview to show Raw used (`pr#68794 `_, Ankush Behl) +* mr/dashboard: remove rgw_servers filter from radosgw-sync-overview grafana dashboard (`pr#68604 `_, Aashish Sharma) +* neorados: Fix Neorados CephContext leak (`pr#67513 `_, Adam C. Emerson) +* neorados: specify alignments for aligned_storage (`pr#67512 `_, Casey Bodley) +* neorados: Various Fixes to Watch/Notify (`pr#68776 `_, Adam C. Emerson, Casey Bodley) +* node-proxy: major refactor and various fixes (`pr#67418 `_, Guillaume Abrioux) +* nvmeof: Change the NVMEOF image version to 1.6 (`pr#68415 `_, Gil Bregman) +* nvmeofgw: fix issue of delete all gws from the pool/group (`pr#67942 `_, Leonid Chernin) +* orch/cephadm: fix osd.default creation (`pr#68121 `_, Guillaume Abrioux) +* os/bluestore: track compression\_\*blob_size\* parameters for online update (`pr#67888 `_, Igor Fedotov) +* os/bluestore:fix bluestore_volume_selection_reserved_factor usage (`pr#66837 `_, Igor Fedotov) +* osd/scrub: support an operator-abort command (`pr#67031 `_, Ronen Friedman) +* osd: add pg-upmap-primary to clean_pg_upmaps (`pr#67407 `_, Laura Flores) +* osd: Avoid assertion on empty object read when reading multiple objects (`pr#68714 `_, Alex Ainscow) +* osd: Avoid pwlc spanning intervals (`pr#68708 `_, Bill Scales) +* osd: Change rmissing map key from version_t to eversion_t (`pr#68716 `_, Alex Ainscow) +* osd: Deleting PG should discard pwlc (`pr#68709 `_, Bill Scales) +* osd: FastEC: always update pwlc epoch when activating (`pr#68710 `_, Bill Scales) +* osd: Fix bug when calculating min_peer_features (`pr#69159 `_, Bill Scales) +* osd: Fix incorrect rollback logic for partial write OI (`pr#68715 `_, Alex Ainscow) +* osd: PGLog Attach correct version to missing list when ignoring log entries (`pr#68718 `_, Alex Ainscow) +* osd: Twiddle should create a full sized vector for optimized EC (`pr#68717 `_, Alex Ainscow) +* pybind/mgr: call new _ceph_exit for killpoints (`pr#68518 `_, Patrick Donnelly) +* pybind/mgr: update modules to use independent CLICommand subtypes with distinct COMMAND attributes (`pr#67511 `_, Kefu Chai, Samuel Just) +* qa/cephadm: derive container image from cephadm release (`pr#68328 `_, Lumir Sliva) +* qa/cephfs: lua to respect missing kernel in yaml (`pr#67293 `_, Kyr Shatskyy) +* qa/cephfs: treat "implicit declaration of function" for blogbench workunit for newer gcc version (`issue#75380 `_, `pr#68820 `_, Venky Shankar) +* qa/distros: add rocky 10.0 as supported distro/container host (`pr#68569 `_, Patrick Donnelly, Casey Bodley, Adam King, David Galloway) +* qa/radosgw_admin: replace boto2 with boto3 (`pr#68739 `_, Adam C. Emerson, Casey Bodley) +* qa/rgw/multisite: remove duplicate test_suspended_delete_marker_incremental_sync (`pr#68846 `_, Oguzhan Ozmen) +* qa/rgw/upgrade: symlinks are explicit about distro versions (`pr#68057 `_, Casey Bodley) +* qa/rgw: Revive crypt kmip (`pr#68371 `_, Kyr Shatskyy) +* qa/suites/fs: fix extraneous distro links (`pr#69251 `_, Patrick Donnelly) +* qa/suites/upgrade: ignore osd in unknown state (`pr#69307 `_, Patrick Donnelly) +* qa/suites/upgrade: ignore undersized PG during stress splits (`pr#69310 `_, Patrick Donnelly) +* qa/suites/upgrade: update upgrade paths and exclude rocky10 from non-supported distros (`pr#68660 `_, Yaarit Hatuka, Laura Flores) +* qa/suites: remove centos restriction from valgrind yaml (`issue#18126 `_, `issue#20360 `_, `pr#67519 `_, Samuel Just) +* qa/suites: use tagged version of reef (`pr#68357 `_, Laura Flores) +* qa/tasks/backfill_toofull.py: Fix assert failures with & without compression (`pr#68118 `_, Sridhar Seshasayee) +* qa/tasks/keystone: upgrade keystone to 2025.2 (`pr#67757 `_, Kyr Shatskyy) +* qa/tasks/quiescer: remove racy assertion (`pr#68510 `_, Patrick Donnelly) +* qa/tasks: capture CommandCrashedError when running nvme list cmd (`pr#69232 `_, Redouane Kachach) +* qa/tasks: make rbd_mirror_thrash inherit from ThrasherGreenlet (`pr#67795 `_, Ilya Dryomov) +* qa/tasks: update egrep to 'grep -E' (`pr#67518 `_, Nitzan Mordechai, Samuel Just) +* qa/workunits/ceph-helpers-root: Add Rocky support for install packages (`pr#67507 `_, Nitzan Mordechai) +* qa/workunits/rbd: drop racy assert in test_tasks_recovery() (`pr#68190 `_, Ilya Dryomov) +* qa/workunits: Add updated kernel archive URL (`pr#68169 `_, Brad Hubbard) +* qa: add 'refresh' config to cephadm.wait_for_service (`pr#67038 `_, Vallari Agrawal) +* qa: add MDS_INSUFFICIENT_STANDBY to ignorelist (`pr#69036 `_, Patrick Donnelly) +* qa: Add nvmeof upgrade from v20.2.0 (`pr#68149 `_, Vallari Agrawal) +* qa: allow multiple mgr sessions during eviction test (`pr#68316 `_, Patrick Donnelly) +* qa: cephfs suite changes for rocky (`pr#68374 `_, Patrick Donnelly) +* qa: Fix coredumps caused by udisks (`pr#67526 `_, Vallari Agrawal) +* qa: Fix nvmeof 'errors during thrashing' (`pr#68148 `_, Vallari Agrawal) +* qa: fix setting rbd_sparse_read_threshold_bytes in test_migration_clone() (`pr#68617 `_, Ilya Dryomov) +* qa: fix TypeError in delay (`pr#67617 `_, Jos Collin) +* qa: fixing cephadm mgmt-gateway test to remove openssl dependency (`pr#67820 `_, Redouane Kachach) +* qa: ignore cephadm failed daemon warnings during thrashing (`pr#69309 `_, Patrick Donnelly) +* qa: ignore POOL_FULL for rbd tests exercising full pools (`pr#69304 `_, Patrick Donnelly) +* qa: install nvme-cli only if distro remains rocky10 (`pr#69252 `_, Patrick Donnelly) +* qa: krbd_rxbounce.sh: do more reads to generate more errors (`pr#67455 `_, Ilya Dryomov) +* qa: Leak_StillReachable RocksDB error_handler (`pr#68524 `_, Nitzan Mordechai) +* qa: rbd_mirror_fsx_compare.sh doesn't error out as expected (`pr#67797 `_, Ilya Dryomov) +* qa: Remove cephadm e2e tests from teuthology (`pr#68818 `_, Afreen Misbah) +* qa: resolve py3.12 regression for random.sample (`pr#68315 `_, Patrick Donnelly) +* qa: suppress false positive delete map mismatch errors (`pr#68431 `_, Casey Bodley, Nitzan Mordechai) +* qa: suppress MismatchedFree operator delete RocksDB (`pr#67508 `_, Nitzan Mordechai) +* rgw/beast: use strand executor for timeout timer to prevent concurrent socket access (`pr#68506 `_, Oguzhan Ozmen) +* rgw/lc: Do not delete DM if its at end of pagination list (`pr#67573 `_, kchheda3) +* rgw/multisite: check the local bucket's versioning status when replicating deletion from remote (`pr#66168 `_, Jane Zhu) +* RGW/multisite: fix bucket-full-sync infinite loop caused by stale bucket_list_result reuse (`pr#67923 `_, Oguzhan Ozmen) +* RGW/Multisite: fix uninitialized LatencyMonitor causing spurious "OSD cluster is overloaded" warning (`pr#68803 `_, Oguzhan Ozmen) +* rgw/s3: Always include x-amz-content-sha256 header in AWS v4 signatures (`pr#66358 `_, Shilpa Jagannath, Matthew N. Heler) +* rgw/tests: add os-specific java 1.7 install commands to keycloak task (`pr#67982 `_, J. Eric Ivancich) +* rgw/website: preserve nameservers for future use in dnsmasq (`pr#67061 `_, Kyr Shatskyy) +* rgw/zone: remove duplicated startup logic in RGWSI_Zone (`pr#66300 `_, Casey Bodley) +* rgw: bucket logging fixes (`pr#66769 `_, Nithya Balachandran, N Balachandran, Yuval Lifshitz, Casey Bodley) +* RGW: Change prerequest hook to run after authorization process (`pr#68594 `_, Emin Sunacoglu) +* rgw: discard olh\_ attributes when copying object from a versioning-suspended bucket to a versioning-disabled bucket (`pr#65557 `_, Jane Zhu) +* rgw: fix 'bucket stats' when bucket index doesn't exist (`pr#68505 `_, Casey Bodley) +* rgw: fix lifecycle transition of encrypted multipart objects (`pr#68826 `_, Marcus Watts) +* rgw: handle plain-text object tags in RGWObjTags::decode() (`pr#67927 `_, Oguzhan Ozmen) +* rgw: java s3-tests change setting of JAVA_HOME (`pr#68226 `_, J. Eric Ivancich) +* rgw: read_obj_policy() consults s3:prefix when deciding between 403/404 (`pr#68651 `_, Casey Bodley) +* RGW: remove custom copy constructor for RGWObjectCtx and enforce no copy/move (`pr#67440 `_, Oguzhan Ozmen) +* src/ceph-volume: fast device unavailable as error (`pr#67916 `_, Timothy Q Nguyen) +* test/rgw/kafka: fix kafka relase to more recent one (`pr#67993 `_, Yuval Lifshitz) +* test/rgw/lua: ignore hours for zero mtime (`pr#67468 `_, Kyr Shatskyy) +* test/rgw/notification: do not use netstat in the code (`pr#68142 `_, Yuval Lifshitz) +* test/rgw/notification: fix the cloudevents package version (`pr#68704 `_, Yuval Lifshitz, Adam C. Emerson) +* test/rgw: remove depracated boto dependency (`pr#68344 `_, Yuval Lifshitz) +* test: use json_extract instead of awkward json_tree (`pr#67321 `_, Patrick Donnelly) +* This change introduces the shared memory communication (SMC-D) for the cluster network (`pr#68254 `_, Aliaksei Makarau) +* tools/ceph-kvstore-tool: fix crash on db close (`pr#68406 `_, Igor Fedotov, Max Kellermann) +* upgrade suites update for Rocky10 and missing centos (`pr#68733 `_, Nitzan Mordechai) + v20.2.1 Tentacle ================ From b52f7f53ae383f8371f451852d444b11fba805b9 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Mon, 15 Jun 2026 19:30:24 -0400 Subject: [PATCH 238/596] doc/releases/tentacle: add more v20.2.2 blocker fixes Resolves: https://tracker.ceph.com/issues/77357 Signed-off-by: Patrick Donnelly --- doc/releases/releases.yml | 2 +- doc/releases/tentacle.rst | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/releases/releases.yml b/doc/releases/releases.yml index b3772b88dbc..6afa8f6464d 100644 --- a/doc/releases/releases.yml +++ b/doc/releases/releases.yml @@ -20,7 +20,7 @@ releases: target_eol: 2027-11-18 releases: - version: 20.2.2 - released: 2026-06-15 + released: 2026-06-16 - version: 20.2.1 released: 2026-04-06 - version: 20.2.0 diff --git a/doc/releases/tentacle.rst b/doc/releases/tentacle.rst index 1de8b03c012..0534c1438ef 100644 --- a/doc/releases/tentacle.rst +++ b/doc/releases/tentacle.rst @@ -12,7 +12,7 @@ This is the second minor release in the Tentacle series. We recommend that all u Release Date ------------ -June 15, 2026 +June 16, 2026 Notable Changes --------------- @@ -71,7 +71,6 @@ External Block Device (extblkdev) Changelog --------- * (tentacle) ceph-volume: backport PRs 67047 and 67240 (`pr#67343 `_, Guillaume Abrioux, Parfait Detchenou) -* 20.2.1 (`pr#68185 `_, Ceph Release Team) * [tentacle] bluestore, extblkdev: Now plugins can raise health warnings (`pr#68663 `_, Adam Kupczyk, Igor Fedotov, Martin Ohmacht) * Backporting PRs 67236 and 67419 (`pr#67533 `_, Adam King) * Beacon diff + Stretched cluster (`pr#68347 `_, Leonid Chernin, Samuel Just) @@ -160,6 +159,7 @@ Changelog * mgr: ensure that all modules have started before advertising active mgr (`pr#67850 `_, Laura Flores) * mgr: fix continous smb MgrDBNotReady (`pr#68598 `_, Pedro Gonzalez Gomez) * mgr: fix PyObject\* refcounting in TTLCache and cleanup logic (`pr#66482 `_, Nitzan Mordechai) +* mgr: guard close_section calls in get_perf_schema_python (`pr#69436 `_, Lumir Sliva) * mgr: isolated CherryPy to prevent global state sharing (`pr#67465 `_, Nizamudeen A, Anmol Babu) * mon [stretch-mode]: Allow a max bucket weight diff threshold (`pr#67790 `_, Kamoltat Sirivadhna, Kamoltat (Junior) Sirivadhna) * mon/AuthMonitor: add osd w cap for superuser client (`pr#68314 `_, Venky Shankar, Patrick Donnelly) @@ -176,6 +176,7 @@ Changelog * os/bluestore:fix bluestore_volume_selection_reserved_factor usage (`pr#66837 `_, Igor Fedotov) * osd/scrub: support an operator-abort command (`pr#67031 `_, Ronen Friedman) * osd: add pg-upmap-primary to clean_pg_upmaps (`pr#67407 `_, Laura Flores) +* osd: Allow multiple objects with same version in missing list (`pr#69450 `_, Alex Ainscow) * osd: Avoid assertion on empty object read when reading multiple objects (`pr#68714 `_, Alex Ainscow) * osd: Avoid pwlc spanning intervals (`pr#68708 `_, Bill Scales) * osd: Change rmissing map key from version_t to eversion_t (`pr#68716 `_, Alex Ainscow) From 0b8c738927b476626d70f64f090767a6742cd8e1 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 03:41:19 +0800 Subject: [PATCH 239/596] crimson,test: remove unused functions and dead variable Fixing these warnings: src/crimson/os/seastore/seastore.cc:83: 'omaptree_initialize' defined but not used [-Wunused-function] src/crimson/osd/replicated_recovery_backend.cc:733: 'nullopt_if_empty' defined but not used [-Wunused-function] src/test/rgw/test_rgw_kms_cache.cc:63: 'rethrow' defined but not used [-Wunused-function] src/test/librados/test_cxx.cc:215: variable 'cmd' set but not used [-Wunused-but-set-variable] Signed-off-by: Sun Yuechi --- src/crimson/os/seastore/seastore.cc | 12 ------------ src/crimson/osd/replicated_recovery_backend.cc | 5 ----- src/test/librados/test_cxx.cc | 4 ---- src/test/rgw/test_rgw_kms_cache.cc | 6 ------ 4 files changed, 27 deletions(-) diff --git a/src/crimson/os/seastore/seastore.cc b/src/crimson/os/seastore/seastore.cc index 324cf6b0a5a..d8165270476 100644 --- a/src/crimson/os/seastore/seastore.cc +++ b/src/crimson/os/seastore/seastore.cc @@ -79,18 +79,6 @@ namespace crimson::os::seastore { using crimson::os::seastore::omap_manager::BtreeOMapManager; using crimson::os::seastore::log_manager::LogManager; -static OMapManager::initialize_omap_ret -omaptree_initialize( - Transaction& t, - BtreeOMapManager& mgr, - omap_type_t type, - Onode& onode, - Device& device) -{ - return mgr.initialize_omap( - t, onode.get_metadata_hint(device.get_block_size()), type); -} - class FileMDStore final : public SeaStore::MDStore { std::string root; public: diff --git a/src/crimson/osd/replicated_recovery_backend.cc b/src/crimson/osd/replicated_recovery_backend.cc index 51d2ceee957..6d43707e14f 100644 --- a/src/crimson/osd/replicated_recovery_backend.cc +++ b/src/crimson/osd/replicated_recovery_backend.cc @@ -730,11 +730,6 @@ ReplicatedRecoveryBackend::read_object_for_push_op( })); } -static std::optional nullopt_if_empty(const std::string& s) -{ - return s.empty() ? std::nullopt : std::make_optional(s); -} - static bool is_too_many_entries_per_chunk(const PushOp* push_op) { const uint64_t entries_per_chunk = diff --git a/src/test/librados/test_cxx.cc b/src/test/librados/test_cxx.cc index 8d153739015..48ee8c64244 100644 --- a/src/test/librados/test_cxx.cc +++ b/src/test/librados/test_cxx.cc @@ -212,10 +212,6 @@ std::string set_pool_flags_pp(const std::string &pool_name, librados::Rados &clu flags ); - char *cmd[2]; - cmd[0] = (char *)cmdstr.c_str(); - cmd[1] = NULL; - int ret = cluster.mon_command(std::move(cmdstr), {}, NULL, NULL); if (ret) { oss << "rados_mon_command osd pool set set_pool_flags_pp failed with error " << ret; diff --git a/src/test/rgw/test_rgw_kms_cache.cc b/src/test/rgw/test_rgw_kms_cache.cc index 7fc3f18fc34..758bcc9e780 100644 --- a/src/test/rgw/test_rgw_kms_cache.cc +++ b/src/test/rgw/test_rgw_kms_cache.cc @@ -60,12 +60,6 @@ class TestKMSCacheReaperLifecycle : public ::testing::Test, : rgw::kms::KMSCache(g_ceph_context, std::make_unique()) {}; }; -static void rethrow(const std::exception_ptr& eptr) { - if (eptr) { - std::rethrow_exception(eptr); - } -} - TEST_F(TestKMSCacheReaperLifecycle, Threaded) { initialize_ttl_reaper(std::nullopt); EXPECT_TRUE(reaper_initialized()); From b943cb6d68de4a80da18e357cbac9b5796c16f17 Mon Sep 17 00:00:00 2001 From: Pritha Srivastava Date: Thu, 28 Aug 2025 12:36:07 +0530 Subject: [PATCH 240/596] rgw/d4n: deleting LFUDAEntry and LFUDAObjEntry instances in LFUDAPolicy destructor. Signed-off-by: Pritha Srivastava --- src/rgw/driver/d4n/d4n_policy.cc | 2 +- src/rgw/driver/d4n/d4n_policy.h | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/rgw/driver/d4n/d4n_policy.cc b/src/rgw/driver/d4n/d4n_policy.cc index ff3c917bebd..78259af039d 100644 --- a/src/rgw/driver/d4n/d4n_policy.cc +++ b/src/rgw/driver/d4n/d4n_policy.cc @@ -392,7 +392,7 @@ int LFUDAPolicy::eviction(const DoutPrefixProvider* dpp, uint64_t size, optional return ret; } - ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Block " << key << " has been evicted." << dendl; + ldpp_dout(dpp, 2) << "LFUDAPolicy::" << __func__ << "(): Block " << key << " has been evicted." << dendl; if (perfcounter) { perfcounter->inc(l_rgw_d4n_cache_evictions); diff --git a/src/rgw/driver/d4n/d4n_policy.h b/src/rgw/driver/d4n/d4n_policy.h index 0a6a9de856e..c51dd8e0d3f 100644 --- a/src/rgw/driver/d4n/d4n_policy.h +++ b/src/rgw/driver/d4n/d4n_policy.h @@ -199,6 +199,12 @@ class LFUDAPolicy : public CachePolicy { quit = true; cond.notify_all(); if (tc.joinable()) { tc.join(); } + for (auto& it : entries_map) { + delete it.second; + } + for (auto& it : o_entries_map) { + delete it.second.first; + } } virtual int init(CephContext *cct, const DoutPrefixProvider* dpp, asio::io_context& io_context, rgw::sal::Driver *_driver); From 1265c6b5cd9ba2e45ac65333fc52f0aa91adada4 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 03:41:27 +0800 Subject: [PATCH 241/596] test/crimson/seastore: use gtest assertion macros instead of assert() Plain assert() is compiled out under NDEBUG, leaving the checked variables unused. Use the always-evaluated gtest macros instead. src/test/crimson/seastore/test_cbjournal.cc:586: variable 'old_written_to' set but not used [-Wunused-but-set-variable] src/test/crimson/seastore/test_btree_lba_manager.cc:345: unused structured binding declaration [-Wunused-variable] Signed-off-by: Sun Yuechi --- src/test/crimson/seastore/test_btree_lba_manager.cc | 5 +++-- src/test/crimson/seastore/test_cbjournal.cc | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/crimson/seastore/test_btree_lba_manager.cc b/src/test/crimson/seastore/test_btree_lba_manager.cc index a312f5c3d03..6d02ec53c2e 100644 --- a/src/test/crimson/seastore/test_btree_lba_manager.cc +++ b/src/test/crimson/seastore/test_btree_lba_manager.cc @@ -342,8 +342,9 @@ struct lba_btree_test : btree_test_base { get_op_context(t), addr, get_map_val(len, extent->get_type()), extent.get() ).si_then([addr, extent](auto p){ - auto& [iter, inserted] = p; - assert(inserted); + // there should be no element at the given addr before insert(), + // so the insertion (p.second) should take place here. + EXPECT_TRUE(p.second); extent->set_laddr(addr); }); }); diff --git a/src/test/crimson/seastore/test_cbjournal.cc b/src/test/crimson/seastore/test_cbjournal.cc index 932485a6476..ffd9c96c938 100644 --- a/src/test/crimson/seastore/test_cbjournal.cc +++ b/src/test/crimson/seastore/test_cbjournal.cc @@ -594,6 +594,6 @@ TEST_F(cbjournal_test_t, multiple_submit_at_end) return Journal::replay_ertr::make_ready_future< std::pair>(true, nullptr); }).unsafe_get(); - assert(get_written_to() == old_written_to); + ASSERT_EQ(get_written_to(), old_written_to); }); } From d3c8d3fc6566d3c909d36875d9ba05345fc90cb2 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 03:41:34 +0800 Subject: [PATCH 242/596] crimson,mgr: mark assert-only variables [[maybe_unused]] These variables are only read inside assert(), which is compiled out under NDEBUG. Mark them [[maybe_unused]] to silence the warnings while keeping the debug-only assert() style used by the surrounding code: src/crimson/os/seastore/lba/btree_lba_manager.cc:1078: unused variable 'orig_len' [-Wunused-variable] src/crimson/os/seastore/omap_manager/log/log_manager.cc:73: variable 'ret' set but not used [-Wunused-but-set-variable] src/crimson/os/seastore/transaction_manager.cc:382: variable 'intermediate_key' set but not used [-Wunused-but-set-variable] src/mgr/PyModule.cc:166,186: unused variable 'r' [-Wunused-variable] Signed-off-by: Sun Yuechi --- src/crimson/os/seastore/lba/btree_lba_manager.cc | 2 +- src/crimson/os/seastore/omap_manager/log/log_manager.cc | 2 +- src/crimson/os/seastore/transaction_manager.cc | 2 +- src/mgr/PyModule.cc | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/crimson/os/seastore/lba/btree_lba_manager.cc b/src/crimson/os/seastore/lba/btree_lba_manager.cc index 7f1bb7f4486..0d71c0969de 100644 --- a/src/crimson/os/seastore/lba/btree_lba_manager.cc +++ b/src/crimson/os/seastore/lba/btree_lba_manager.cc @@ -1075,7 +1075,7 @@ BtreeLBAManager::remap_mappings( assert(cursor->is_viewable()); auto orig_indirect = cursor->is_indirect(); auto orig_laddr = cursor->get_laddr(); - auto orig_len = cursor->get_length(); + [[maybe_unused]] auto orig_len = cursor->get_length(); auto c = get_context(t); auto btree = co_await get_btree(cache, c); auto iter = btree.make_partial_iter(c, *cursor); diff --git a/src/crimson/os/seastore/omap_manager/log/log_manager.cc b/src/crimson/os/seastore/omap_manager/log/log_manager.cc index f06a45bb2b1..03f16875c5c 100644 --- a/src/crimson/os/seastore/omap_manager/log/log_manager.cc +++ b/src/crimson/os/seastore/omap_manager/log/log_manager.cc @@ -70,7 +70,7 @@ LogManager::omap_set_keys( auto resync_node = [&](LogNodeRef e) -> log_load_extent_iertr::future { CachedExtentRef node; - Transaction::get_extent_ret ret; + [[maybe_unused]] Transaction::get_extent_ret ret; // To find mutable extent in the same transaction ret = t.get_extent(e->get_paddr(), &node); assert(ret == Transaction::get_extent_ret::PRESENT); diff --git a/src/crimson/os/seastore/transaction_manager.cc b/src/crimson/os/seastore/transaction_manager.cc index f0fd3509cea..f0e586220b9 100644 --- a/src/crimson/os/seastore/transaction_manager.cc +++ b/src/crimson/os/seastore/transaction_manager.cc @@ -379,7 +379,7 @@ TransactionManager::resolve_cursor_to_mapping( ceph_assert(direct_cursors.size() == 1); auto& direct_cursor = direct_cursors.front(); - auto intermediate_key = cursor->get_intermediate_key(); + [[maybe_unused]] auto intermediate_key = cursor->get_intermediate_key(); assert(!direct_cursor->is_indirect()); assert(direct_cursor->get_laddr() <= intermediate_key); assert(direct_cursor->get_laddr() + direct_cursor->get_length() diff --git a/src/mgr/PyModule.cc b/src/mgr/PyModule.cc index 46dc97073b8..40531f6db8f 100644 --- a/src/mgr/PyModule.cc +++ b/src/mgr/PyModule.cc @@ -163,7 +163,7 @@ std::span py_bytes_as_span(PyObject *bytes) assert(PyBytes_CheckExact(bytes)); Py_ssize_t length; char *buf; - int r = PyBytes_AsStringAndSize( + [[maybe_unused]] int r = PyBytes_AsStringAndSize( bytes, &buf, &length); assert(r == 0); return std::span((const std::byte*)buf, size_t(length)); @@ -183,7 +183,7 @@ std::vector py_bytes_as_vec(PyObject *bytes) assert(PyBytes_CheckExact(bytes)); Py_ssize_t length; char *buf; - int r = PyBytes_AsStringAndSize( + [[maybe_unused]] int r = PyBytes_AsStringAndSize( bytes, &buf, &length); assert(r == 0); return std::vector{ From e3d9ebb50e914bfa8a2518a7f22024b0d10f375b Mon Sep 17 00:00:00 2001 From: Tomer Haskalovitch Date: Tue, 2 Jun 2026 13:20:28 +0300 Subject: [PATCH 243/596] mgr/dashboard: align nvmeof cli with missing parameters and functions from the old nvmeof cli fixes: https://tracker.ceph.com/issues/77108 Signed-off-by: Tomer Haskalovitch --- .../mgr/dashboard/controllers/nvmeof.py | 245 +++++++++++++++- src/pybind/mgr/dashboard/model/nvmeof.py | 42 +++ src/pybind/mgr/dashboard/openapi.yaml | 274 +++++++++++++++++- 3 files changed, 553 insertions(+), 8 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index d6f7628803c..557ea546ef1 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-lines import logging from functools import partial -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import cherrypy from orchestrator import OrchestratorError @@ -299,6 +299,37 @@ else: ) return io_stats + @ReadPermission + @Endpoint('GET', '/thread_stats') + @NvmeofCLICommand( + "nvmeof gateway get_thread_stats", model.ThreadStatsInfo, + alias="nvmeof gw get_thread_stats") + @EndpointDoc( + "Get NVMeoF thread statistics for the gateway", + parameters={ + "gw_group": Param(str, "NVMeoF gateway group", True, None), + "server_address": Param(str, "NVMeoF gateway address", True, None), + "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), + } + ) + @convert_to_model(model.ThreadStatsInfo) + @handle_nvmeof_error + def get_thread_stats( + self, gw_group: Optional[str] = None, + server_address: Optional[str] = None, + traddr: Optional[str] = None + ): + server_address = resolve_nvmeof_server_address( + server_address=server_address, + traddr=traddr + ) + return NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.get_thread_stats( + NVMeoFClient.pb2.get_thread_stats_req() + ) + @APIRouter("/nvmeof/spdk", Scope.NVME_OF) @APIDoc("NVMe-oF SPDK Management API", "NVMe-oF SPDK") class NVMeoFSpdk(RESTController): @@ -416,6 +447,8 @@ else: @EndpointDoc( "List all NVMeoF subsystems", parameters={ + "nqn": Param(str, "NVMeoF subsystem NQN to filter by", True, None), + "serial_number": Param(str, "Serial number to filter by", True, None), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), @@ -423,7 +456,8 @@ else: ) @convert_to_model(model.SubsystemList) @handle_nvmeof_error - def list(self, gw_group: Optional[str] = None, server_address: Optional[str] = None, + def list(self, nqn: Optional[str] = None, serial_number: Optional[str] = None, + gw_group: Optional[str] = None, server_address: Optional[str] = None, traddr: Optional[str] = None): server_address = resolve_nvmeof_server_address( server_address=server_address, @@ -433,7 +467,8 @@ else: gw_group=gw_group, server_address=server_address ).stub.list_subsystems( - NVMeoFClient.pb2.list_subsystems_req() + NVMeoFClient.pb2.list_subsystems_req(subsystem_nqn=nqn, + serial_number=serial_number) ) @pick(field="subsystems", first=True) @@ -860,6 +895,12 @@ else: "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), "secure": Param(bool, "Use a secure channel", True, False), + "force": Param( + bool, + "Allow contradiction in security with existing listeners", + True, + False + ), "verify_host_name": Param(bool, "Fail if the host name doesn't match the " "gateway's host name", @@ -878,6 +919,7 @@ else: gw_group: Optional[str] = None, server_address: Optional[str] = None, secure: Optional[bool] = False, + force: Optional[bool] = False, verify_host_name: Optional[bool] = False, ): client = NVMeoFClient( @@ -892,6 +934,7 @@ else: trsvcid=int(trsvcid) if trsvcid is not None else None, adrfam=int(adrfam), secure=str_to_bool(secure), + force=str_to_bool(force), verify_host_name=str_to_bool(verify_host_name), ) ) @@ -919,6 +962,15 @@ else: "traddr": Param(str, "NVMeoF transport address"), "trsvcid": Param(int, "NVMeoF transport service port"), "adrfam": Param(int, "NVMeoF address family (0 - IPv4, 1 - IPv6)", True, 0), + "force": Param( + bool, + ( + "Delete listener even if there are active connections " + "or host name doesn't match" + ), + True, + False + ), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), }, @@ -963,6 +1015,7 @@ else: parameters={ "nqn": Param(str, "NVMeoF subsystem NQN", True, None), "nsid": Param(str, "NVMeoF Namespace ID to filter by", True, None), + "uuid": Param(str, "NVMeoF Namespace UUID to filter by", True, None), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), @@ -971,6 +1024,7 @@ else: @convert_to_model(model.NamespaceList) @handle_nvmeof_error def list(self, nqn: Optional[str] = None, nsid: Optional[str] = None, + uuid: Optional[str] = None, gw_group: Optional[str] = None, server_address: Optional[str] = None, traddr: Optional[str] = None): server_address = resolve_nvmeof_server_address( @@ -982,7 +1036,8 @@ else: server_address=server_address ).stub.list_namespaces( NVMeoFClient.pb2.list_namespaces_req(subsystem=nqn, - nsid=int(nsid) if nsid else None) + nsid=int(nsid) if nsid else None, + uuid=uuid) ) @pick("namespaces", first=True) @@ -1055,7 +1110,8 @@ else: "rados_namespace": Param(str, "RADOS namespace name", True, None), "rbd_pool": Param(str, "RBD pool name"), "rbd_data_pool": Param(str, "RBD data pool name", True, None), - "nsid": Param(str, "Create RBD image", True, None), + "nsid": Param(str, "Namespace ID", True, None), + "uuid": Param(str, "UUID", True, None), "create_image": Param(bool, "Create RBD image"), "size": Param(int, "Deprecated. Use `rbd_image_size` instead"), "rbd_image_size": Param(int, "RBD image size"), @@ -1098,6 +1154,7 @@ else: rbd_pool: str = "rbd", rbd_data_pool: Optional[str] = None, nsid: Optional[str] = None, + uuid: Optional[str] = None, create_image: Optional[bool] = False, size: Optional[int] = None, rbd_image_size: Optional[int] = None, @@ -1143,6 +1200,7 @@ else: NVMeoFClient.pb2.namespace_add_req( subsystem_nqn=nqn, nsid=int(nsid) if nsid else None, + uuid=uuid, rbd_image_name=rbd_image_name, rados_namespace_name=rados_namespace, rbd_pool_name=rbd_pool, @@ -1176,6 +1234,8 @@ else: "rbd_data_pool": Param(str, "RBD data pool name", True, None), "rados_namespace": Param(str, "RADOS namespace name", True, None), "rbd_image_name": Param(str, "RBD image name"), + "nsid": Param(str, "Namespace ID", True, None), + "uuid": Param(str, "UUID", True, None), "create_image": Param(bool, "Create RBD image"), "size": Param(str, "Deprecated. Use `rbd_image_size` instead", True, None), "rbd_image_size": Param(str, "RBD image size", True, None), @@ -1218,6 +1278,7 @@ else: rbd_pool: str = "rbd", rbd_data_pool: Optional[str] = None, nsid: Optional[str] = None, + uuid: Optional[str] = None, create_image: Optional[bool] = False, size: Optional[str] = None, rbd_image_size: Optional[str] = None, @@ -1278,6 +1339,7 @@ else: NVMeoFClient.pb2.namespace_add_req( subsystem_nqn=nqn, nsid=int(nsid) if nsid else None, + uuid=uuid, rbd_image_name=rbd_image_name, rados_namespace_name=rados_namespace, rbd_pool_name=rbd_pool, @@ -1638,6 +1700,170 @@ else: ) ) + @ReadPermission + @Endpoint('PUT', '{nsid}/change_location') + @NvmeofCLICommand( + "nvmeof namespace change_location", + model=model.RequestStatus, + alias="nvmeof ns change_location", + success_message_template=( + 'Setting location for namespace {nsid} in {nqn} to "{location}": Successful' + ) + ) + @EndpointDoc( + "Change the location of the specified NVMeoF namespace", + parameters={ + "nqn": Param(str, "NVMeoF subsystem NQN"), + "nsid": Param(str, "NVMeoF Namespace ID"), + "location": Param(str, "Gateway location for namespace"), + "gw_group": Param(str, "NVMeoF gateway group", True, None), + "server_address": Param(str, "NVMeoF gateway address", True, None), + "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), + }, + ) + @convert_to_model(model.RequestStatus) + @handle_nvmeof_error + def change_location( + self, + nqn: str, + nsid: str, + location: str, + gw_group: Optional[str] = None, + server_address: Optional[str] = None, + traddr: Optional[str] = None + ): + server_address = resolve_nvmeof_server_address( + server_address=server_address, + traddr=traddr + ) + return NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.namespace_change_location( + NVMeoFClient.pb2.namespace_change_location_req( + subsystem_nqn=nqn, + nsid=int(nsid), + location=location, + ) + ) + + @ReadPermission + @Endpoint('GET', 'list_hosts') + @NvmeofCLICommand( + "nvmeof namespace list_hosts", + model=model.NamespaceHostsList, + alias="nvmeof ns list_hosts" + ) + @EndpointDoc( + "List all NVMeoF namespaces with their allowed hosts", + parameters={ + "nqn": Param(str, "NVMeoF subsystem NQN", True, None), + "nsid": Param(str, "NVMeoF Namespace ID to filter by", True, None), + "uuid": Param(str, "NVMeoF Namespace UUID to filter by", True, None), + "gw_group": Param(str, "NVMeoF gateway group", True, None), + "server_address": Param(str, "NVMeoF gateway address", True, None), + "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), + }, + ) + @handle_nvmeof_error + def list_hosts( + self, nqn: Optional[str] = None, nsid: Optional[str] = None, + uuid: Optional[str] = None, + gw_group: Optional[str] = None, server_address: Optional[str] = None, + traddr: Optional[str] = None + ): + server_address = resolve_nvmeof_server_address( + server_address=server_address, + traddr=traddr + ) + ns_list = NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.list_namespaces( + NVMeoFClient.pb2.list_namespaces_req(subsystem=nqn, + nsid=int(nsid) if nsid else None, + uuid=uuid) + ) + + # Transform to NamedTuple with only NQN, NSID, and Hosts + host_infos = [] + for ns in ns_list.namespaces: + host_infos.append(model.NamespaceHostInfo( + nqn=ns.ns_subsystem_nqn or nqn or "", + nsid=ns.nsid, + hosts=list(ns.hosts) + )) + + return model.NamespaceHostsList( + status=ns_list.status, + error_message=ns_list.error_message, + namespaces=host_infos + ) + + @ReadPermission + @Endpoint('GET', 'list_locations') + @NvmeofCLICommand( + "nvmeof namespace list_locations", + model=model.NamespaceLocationsList, + alias="nvmeof ns list_locations" + ) + @EndpointDoc( + "List namespace distribution per site locations", + parameters={ + "nqn": Param(str, "NVMeoF subsystem NQN", True, None), + "nsid": Param(str, "NVMeoF Namespace ID to filter by", True, None), + "uuid": Param(str, "NVMeoF Namespace UUID to filter by", True, None), + "gw_group": Param(str, "NVMeoF gateway group", True, None), + "server_address": Param(str, "NVMeoF gateway address", True, None), + "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), + }, + ) + @handle_nvmeof_error + def list_locations( + self, nqn: Optional[str] = None, nsid: Optional[str] = None, + uuid: Optional[str] = None, + gw_group: Optional[str] = None, server_address: Optional[str] = None, + traddr: Optional[str] = None + ): + server_address = resolve_nvmeof_server_address( + server_address=server_address, + traddr=traddr + ) + ns_list = NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.list_namespaces( + NVMeoFClient.pb2.list_namespaces_req(subsystem=nqn, + nsid=int(nsid) if nsid else None, + uuid=uuid) + ) + + # Aggregate namespaces by subsystem, load balancing group, and location + location_counts: Dict[Tuple[str, int, str], int] = {} + for ns in ns_list.namespaces: + subsystem_nqn = ns.ns_subsystem_nqn or nqn or "" + lb_group = ns.load_balancing_group if ns.load_balancing_group else 0 + location = ns.location if ns.location else "" + + key = (subsystem_nqn, lb_group, location) + location_counts[key] = location_counts.get(key, 0) + 1 + + # Convert to NamedTuple list + location_infos = [] + for (subsystem_nqn, lb_group, location), count in sorted(location_counts.items()): + location_infos.append(model.NamespaceLocationInfo( + subsystem=subsystem_nqn, + load_balancing_group=lb_group, + location=location, + namespace_count=count + )) + + return model.NamespaceLocationsList( + status=ns_list.status, + error_message=ns_list.error_message, + locations=location_infos + ) + @ReadPermission @Endpoint('PUT', '{nsid}/set_auto_resize') @NvmeofCLICommand( @@ -2064,6 +2290,10 @@ else: parameters={ "nqn": Param(str, "NVMeoF subsystem NQN"), "host_nqn": Param(str, 'NVMeoF host NQN. Use "*" to disallow any host.'), + "force": Param( + bool, + "Delete the host even if it used in a namespace netmask", + True, False), "gw_group": Param(str, "NVMeoF gateway group", True, None), "server_address": Param(str, "NVMeoF gateway address", True, None), "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), @@ -2071,7 +2301,8 @@ else: ) @convert_to_model(model.RequestStatus) @handle_nvmeof_error - def delete(self, nqn: str, host_nqn: str, gw_group: Optional[str] = None, + def delete(self, nqn: str, host_nqn: str, force: Optional[bool] = False, + gw_group: Optional[str] = None, server_address: Optional[str] = None, traddr: Optional[str] = None): server_address = resolve_nvmeof_server_address( @@ -2082,7 +2313,7 @@ else: gw_group=gw_group, server_address=server_address ).stub.remove_host( - NVMeoFClient.pb2.remove_host_req(subsystem_nqn=nqn, host_nqn=host_nqn) + NVMeoFClient.pb2.remove_host_req(subsystem_nqn=nqn, host_nqn=host_nqn, force=force) ) @empty_response diff --git a/src/pybind/mgr/dashboard/model/nvmeof.py b/src/pybind/mgr/dashboard/model/nvmeof.py index d832eb26140..f4b7327514e 100644 --- a/src/pybind/mgr/dashboard/model/nvmeof.py +++ b/src/pybind/mgr/dashboard/model/nvmeof.py @@ -207,6 +207,35 @@ class NamespaceList(NamedTuple): namespaces: Annotated[List[Namespace], CliFlags.EXCLUSIVE_LIST] +class NamespaceHostInfo(NamedTuple): + nqn: Annotated[str, CliHeader("NQN")] + nsid: Annotated[int, CliHeader("NSID")] + hosts: Annotated[ + List[str], + CliHeader("Hosts"), + CliFieldTransformer(lambda v: "\n".join(v) if v else "None") + ] + + +class NamespaceHostsList(NamedTuple): + status: int + error_message: str + namespaces: Annotated[List[NamespaceHostInfo], CliFlags.EXCLUSIVE_LIST] + + +class NamespaceLocationInfo(NamedTuple): + subsystem: Annotated[str, CliHeader("Subsystem")] + load_balancing_group: Annotated[int, CliHeader("Load Balancing Group")] + location: Annotated[str, CliHeader("Location")] + namespace_count: Annotated[int, CliHeader("Count")] + + +class NamespaceLocationsList(NamedTuple): + status: int + error_message: str + locations: Annotated[List[NamespaceLocationInfo], CliFlags.EXCLUSIVE_LIST] + + class NamespaceIOStats(NamedTuple): status: Annotated[int, CliFlags.DROP] error_message: Annotated[str, CliFlags.DROP] @@ -292,6 +321,19 @@ class GatewayStatsInfo(NamedTuple): poll_groups: Annotated[List[PollGroupInfo], CliFlags.EXCLUSIVE_LIST] +class SpdkThreadInfo(NamedTuple): + name: str + busy: int + idle: int + + +class ThreadStatsInfo(NamedTuple): + status: int + error_message: str + tick_rate: int + threads: Annotated[List[SpdkThreadInfo], CliFlags.EXCLUSIVE_LIST] + + class AnaState(Enum): UNSET = 0 OPTIMIZED = 1 diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index abb7a7351ff..2ddd1190b47 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -12961,6 +12961,51 @@ paths: summary: Get NVMeoF statistics for the gateway tags: - NVMe-oF Gateway + /api/nvmeof/gateway/thread_stats: + get: + parameters: + - allowEmptyValue: true + description: NVMeoF gateway group + in: query + name: gw_group + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address + in: query + name: server_address + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address (deprecated) + in: query + name: traddr + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Get NVMeoF thread statistics for the gateway + tags: + - NVMe-oF Gateway /api/nvmeof/gateway/version: get: parameters: @@ -13177,6 +13222,18 @@ paths: /api/nvmeof/subsystem: get: parameters: + - allowEmptyValue: true + description: NVMeoF subsystem NQN to filter by + in: query + name: nqn + schema: + type: string + - allowEmptyValue: true + description: Serial number to filter by + in: query + name: serial_number + schema: + type: string - allowEmptyValue: true description: NVMeoF gateway group in: query @@ -13599,6 +13656,12 @@ paths: required: true schema: type: string + - default: false + description: Delete the host even if it used in a namespace netmask + in: query + name: force + schema: + type: boolean - allowEmptyValue: true description: NVMeoF gateway group in: query @@ -13980,6 +14043,10 @@ paths: default: 0 description: NVMeoF address family (0 - IPv4, 1 - IPv6) type: integer + force: + default: false + description: Allow contradiction in security with existing listeners + type: boolean gw_group: description: NVMeoF gateway group type: string @@ -14075,6 +14142,8 @@ paths: schema: type: integer - default: false + description: Delete listener even if there are active connections or host + name doesn't match in: query name: force schema: @@ -14139,6 +14208,12 @@ paths: name: nsid schema: type: string + - allowEmptyValue: true + description: NVMeoF Namespace UUID to filter by + in: query + name: uuid + schema: + type: string - allowEmptyValue: true description: NVMeoF gateway group in: query @@ -14240,7 +14315,7 @@ paths: description: Namespace will be visible only for the allowed hosts type: boolean nsid: - description: Create RBD image + description: Namespace ID type: string rados_namespace: description: RADOS namespace name @@ -14275,6 +14350,9 @@ paths: default: false description: Trash the RBD image when namespace is removed type: boolean + uuid: + description: UUID + type: string required: - rbd_image_name type: object @@ -14311,6 +14389,132 @@ paths: summary: Create a new NVMeoF namespace. tags: - NVMe-oF Subsystem Namespace + /api/nvmeof/subsystem/{nqn}/namespace/list_hosts: + get: + parameters: + - allowEmptyValue: true + description: NVMeoF subsystem NQN + in: path + name: nqn + schema: + type: string + - allowEmptyValue: true + description: NVMeoF Namespace ID to filter by + in: query + name: nsid + schema: + type: string + - allowEmptyValue: true + description: NVMeoF Namespace UUID to filter by + in: query + name: uuid + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway group + in: query + name: gw_group + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address + in: query + name: server_address + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address (deprecated) + in: query + name: traddr + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: List all NVMeoF namespaces with their allowed hosts + tags: + - NVMe-oF Subsystem Namespace + /api/nvmeof/subsystem/{nqn}/namespace/list_locations: + get: + parameters: + - allowEmptyValue: true + description: NVMeoF subsystem NQN + in: path + name: nqn + schema: + type: string + - allowEmptyValue: true + description: NVMeoF Namespace ID to filter by + in: query + name: nsid + schema: + type: string + - allowEmptyValue: true + description: NVMeoF Namespace UUID to filter by + in: query + name: uuid + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway group + in: query + name: gw_group + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address + in: query + name: server_address + schema: + type: string + - allowEmptyValue: true + description: NVMeoF gateway address (deprecated) + in: query + name: traddr + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: List namespace distribution per site locations + tags: + - NVMe-oF Subsystem Namespace /api/nvmeof/subsystem/{nqn}/namespace/{nsid}: delete: parameters: @@ -14654,6 +14858,74 @@ paths: summary: set the load balancing group for specified NVMeoF namespace tags: - NVMe-oF Subsystem Namespace + /api/nvmeof/subsystem/{nqn}/namespace/{nsid}/change_location: + put: + parameters: + - description: NVMeoF subsystem NQN + in: path + name: nqn + required: true + schema: + type: string + - description: NVMeoF Namespace ID + in: path + name: nsid + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + gw_group: + description: NVMeoF gateway group + type: string + location: + description: Gateway location for namespace + type: string + server_address: + description: NVMeoF gateway address + type: string + traddr: + description: NVMeoF gateway address (deprecated) + type: string + required: + - location + type: object + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource updated. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Change the location of the specified NVMeoF namespace + tags: + - NVMe-oF Subsystem Namespace /api/nvmeof/subsystem/{nqn}/namespace/{nsid}/change_visibility: put: parameters: From c1dbe78a1490d001b1c9115f071a04fc3280a93f Mon Sep 17 00:00:00 2001 From: Pritha Srivastava Date: Mon, 1 Sep 2025 14:26:17 +0530 Subject: [PATCH 244/596] rgw/d4n: adding a thread to asynchronously update localweight to the cache backend. Removing the code to update the localweight from GET and PUT requests. Signed-off-by: Pritha Srivastava --- src/common/options/rgw.yaml.in | 10 ++ src/rgw/driver/d4n/d4n_policy.cc | 213 ++++++++++++++++++++----------- src/rgw/driver/d4n/d4n_policy.h | 15 ++- 3 files changed, 159 insertions(+), 79 deletions(-) diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index d9111ffeb07..06defc466e1 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -4468,6 +4468,16 @@ options: flags: - startup with_legacy: true +- name: rgw_d4n_localweight_processing_interval + type: int + level: advanced + desc: This is the interval in seconds for invoking local weight writer thread + default: 3600 + services: + - rgw + flags: + - startup + with_legacy: true - name: rgw_topic_persistency_time_to_live type: uint level: advanced diff --git a/src/rgw/driver/d4n/d4n_policy.cc b/src/rgw/driver/d4n/d4n_policy.cc index 78259af039d..45122f5436c 100644 --- a/src/rgw/driver/d4n/d4n_policy.cc +++ b/src/rgw/driver/d4n/d4n_policy.cc @@ -71,6 +71,9 @@ int LFUDAPolicy::init(CephContext* cct, const DoutPrefixProvider* dpp, asio::io_ tc = std::thread(&CachePolicy::cleaning, this, dpp); } + lwthread = std::thread(&LFUDAPolicy::localweight_writer, this, dpp); + lw_quit = false; + try { boost::system::error_code ec; response< @@ -432,74 +435,80 @@ void LFUDAPolicy::update(const DoutPrefixProvider* dpp, const std::string& key, { ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): updating entry: " << key << dendl; using handle_type = boost::heap::fibonacci_heap>>::handle_type; - const std::lock_guard l(lfuda_lock); - int localWeight = age; - auto entry = find_entry(key); - bool updateLocalWeight = true; - uint64_t refcount = 0; + bool updateLocalWeight = true, should_notify = false; + { + const std::lock_guard l(lfuda_lock); + int localWeight = age; + auto entry = find_entry(key); + uint64_t refcount = 0; + if (!restore_val.empty()) { + updateLocalWeight = false; + localWeight = std::stoull(restore_val); + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): restored localWeight is: " << localWeight << dendl; + } - if (!restore_val.empty()) { - updateLocalWeight = false; - localWeight = std::stoull(restore_val); - ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): restored localWeight is: " << localWeight << dendl; - } - - /* check the dirty flag in the existing entry for the key and the incoming dirty flag. If the - incoming dirty flag is false, that means update() is invoked as part of cleaning process, - so we must not update its localWeight. */ - if (entry) { - refcount = entry->refcount; - if (entry->dirty && dirty.has_value()) { - bool is_dirty = dirty.value(); - if (!is_dirty) { - localWeight = entry->localWeight; - updateLocalWeight = false; + /* check the dirty flag in the existing entry for the key and the incoming dirty flag. If the + incoming dirty flag is false, that means update() is invoked as part of cleaning process, + so we must not update its localWeight. */ + if (entry) { + refcount = entry->refcount; + if (entry->dirty && dirty.has_value()) { + bool is_dirty = dirty.value(); + if (!is_dirty) { + localWeight = entry->localWeight; + updateLocalWeight = false; + } + } + if (updateLocalWeight) { + localWeight = entry->localWeight + age; + } + if (op == RefCount::INCR) { + refcount += 1; + } + if (op == RefCount::DECR) { + if (refcount > 0) { + refcount -= 1; + } } } + //pick the existing value of dirty, if no value has been passed in + bool is_dirty = false; + if (dirty.has_value()) { + is_dirty = dirty.value(); + } else if (entry) { + is_dirty = entry->dirty; + } + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): updated refcount is: " << refcount << dendl; + + if (entry) { + entry->key = key; + entry->offset = offset; + entry->len = len; + entry->version = version; + entry->dirty = is_dirty; + entry->refcount = refcount; + entry->localWeight = localWeight; + entries_heap.update(entry->handle, entry); + } else { + LFUDAEntry* e = new LFUDAEntry(key, offset, len, version, is_dirty, refcount, localWeight); + handle_type handle = entries_heap.push(e); + e->set_handle(handle); + entries_map.emplace(key, e); + } + if (updateLocalWeight) { - localWeight = entry->localWeight + age; - } - if (op == RefCount::INCR) { - refcount += 1; - } - if (op == RefCount::DECR) { - if (refcount > 0) { - refcount -= 1; + updated_blocks.emplace(key, localWeight); + if (updated_blocks.size() >= LOCALWEIGHT_BATCH_SIZE) { + should_notify = true; } } - } - //pick the existing value of dirty, if no value has been passed in - bool is_dirty = false; - if (dirty.has_value()) { - is_dirty = dirty.value(); - } else if (entry) { - is_dirty = entry->dirty; - } - ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): updated refcount is: " << refcount << dendl; - if (entry) { - entry->key = key; - entry->offset = offset; - entry->len = len; - entry->version = version; - entry->dirty = is_dirty; - entry->refcount = refcount; - entry->localWeight = localWeight; - entries_heap.update(entry->handle, entry); - } else { - LFUDAEntry* e = new LFUDAEntry(key, offset, len, version, is_dirty, refcount, localWeight); - handle_type handle = entries_heap.push(e); - e->set_handle(handle); - entries_map.emplace(key, e); + weightSum += ((localWeight < 0) ? 0 : localWeight); + } //lock will be released here + if (should_notify) { + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): notify_one: "<< dendl; + lw_cond.notify_one(); } - - if (updateLocalWeight) { - int ret = -1; - if ((ret = cacheDriver->set_attr(dpp, key, RGW_CACHE_ATTR_LOCAL_WEIGHT, std::to_string(localWeight), y)) < 0) - ldpp_dout(dpp, 0) << "LFUDAPolicy::" << __func__ << "(): CacheDriver set_attr method failed, ret=" << ret << dendl; - } - - weightSum += ((localWeight < 0) ? 0 : localWeight); } void LFUDAPolicy::update_dirty_object(const DoutPrefixProvider* dpp, const std::string& key, const std::string& version, bool deleteMarker, uint64_t size, double creationTime, const rgw_user& user, const std::string& etag, const std::string& bucket_name, const std::string& bucket_id, const rgw_obj_key& obj_key, uint8_t op, optional_yield y, std::string& restore_val) @@ -515,11 +524,13 @@ void LFUDAPolicy::update_dirty_object(const DoutPrefixProvider* dpp, const std:: state = State::INIT; } - const std::lock_guard l(lfuda_cleaning_lock); - LFUDAObjEntry* e = new LFUDAObjEntry{key, version, deleteMarker, size, creationTime, user, etag, bucket_name, bucket_id, obj_key}; - handle_type handle = object_heap.push(e); - e->set_handle(handle); - o_entries_map.emplace(key, std::make_pair(e, state)); + { + const std::lock_guard l(lfuda_cleaning_lock); + LFUDAObjEntry* e = new LFUDAObjEntry{key, version, deleteMarker, size, creationTime, user, etag, bucket_name, bucket_id, obj_key}; + handle_type handle = object_heap.push(e); + e->set_handle(handle); + o_entries_map.emplace(key, std::make_pair(e, state)); + } cond.notify_one(); } @@ -548,18 +559,19 @@ bool LFUDAPolicy::erase(const DoutPrefixProvider* dpp, const std::string& key, o bool LFUDAPolicy::erase_dirty_object(const DoutPrefixProvider* dpp, const std::string& key, optional_yield y) { - const std::lock_guard l(lfuda_cleaning_lock); - auto p = o_entries_map.find(key); - if (p == o_entries_map.end()) { - return false; + { + const std::lock_guard l(lfuda_cleaning_lock); + auto p = o_entries_map.find(key); + if (p == o_entries_map.end()) { + return false; + } + + object_heap.erase(p->second.first->handle); + delete p->second.first; + p->second.first = nullptr; + o_entries_map.erase(p); } - - object_heap.erase(p->second.first->handle); - delete p->second.first; - p->second.first = nullptr; - o_entries_map.erase(p); state_cond.notify_one(); - return true; } @@ -908,7 +920,7 @@ void LFUDAPolicy::cleaning(const DoutPrefixProvider* dpp) } } //end-if (block.version == entry->version) } //end - else if op_ret == 0 - ldpp_dout(dpp, 10) << "D4NFilterObject::" << __func__ << "(): Removing object name: "<< c_obj->get_name() << " score: " << std::setprecision(std::numeric_limits::max_digits10) << e->creationTime << " from ordered set" << dendl; + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Removing object name: "<< c_obj->get_name() << " score: " << std::setprecision(std::numeric_limits::max_digits10) << e->creationTime << " from ordered set" << dendl; rgw::d4n::CacheObj dir_obj = rgw::d4n::CacheObj{ .objName = c_obj->get_name(), .bucketName = c_obj->get_bucket()->get_bucket_id(), @@ -962,7 +974,7 @@ void LFUDAPolicy::cleaning(const DoutPrefixProvider* dpp) continue; } } - ldpp_dout(dpp, 10) << "D4NFilterObject::" << __func__ << "(): Removing object name: "<< c_obj->get_name() << " score: " << std::setprecision(std::numeric_limits::max_digits10) << e->creationTime << " from ordered set" << dendl; + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Removing object name: "<< c_obj->get_name() << " score: " << std::setprecision(std::numeric_limits::max_digits10) << e->creationTime << " from ordered set" << dendl; rgw::d4n::CacheObj dir_obj = rgw::d4n::CacheObj{ .objName = c_obj->get_name(), .bucketName = c_obj->get_bucket()->get_bucket_id(), @@ -988,6 +1000,53 @@ void LFUDAPolicy::cleaning(const DoutPrefixProvider* dpp) } //end-while true } +void LFUDAPolicy::localweight_writer(const DoutPrefixProvider* dpp) +{ + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Starting thread " << dendl; + auto TIMEOUT_DURATION = std::chrono::seconds(dpp->get_cct()->_conf->rgw_d4n_localweight_processing_interval); + while (!lw_quit.load()) { + std::unordered_map temp; + bool woke_up = false; + //sleep for some duration or till size crosses 10K before processing + { + std::unique_lock wait_lock(lfuda_lock); + woke_up = lw_cond.wait_for(wait_lock, TIMEOUT_DURATION, [this] { + return updated_blocks.size() >= LOCALWEIGHT_BATCH_SIZE || lw_quit.load(); + }); + if (lw_quit.load()) { + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Quit signal received, exiting" << dendl; + break; + } + if (!updated_blocks.empty()) { + updated_blocks.swap(temp); + if (woke_up) { + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Woke up due to size threshold, processing " << temp.size() << " items" << dendl; + } else { + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Woke up due to timeout, processing " << temp.size() << " items" << dendl; + } + } + } //lock released here + if (!temp.empty()) { + ldpp_dout(dpp, 5) << "LFUDAPolicy::" << __func__ << "(): Processing batch of " << temp.size() << " items" << dendl; + for (auto& it : temp) { + if (lw_quit.load()) { + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): Quit signal received, exiting" << lw_quit << dendl; + break; + } + auto& key = it.first; + auto localWeight = it.second; + ldpp_dout(dpp, 10) << "LFUDAPolicy::" << __func__ << "(): CacheDriver set_attr method called for key: " << key << dendl; + int ret = cacheDriver->set_attr(dpp, key, RGW_CACHE_ATTR_LOCAL_WEIGHT, std::to_string(localWeight), y); + if (ret < 0) { + ldpp_dout(dpp, 0) << "LFUDAPolicy::" << __func__ << "(): CacheDriver set_attr method failed, ret=" << ret << dendl; + } + } //end-for + ldpp_dout(dpp, 5) << "LFUDAPolicy::" << __func__ << "(): Finished processing batch" << dendl; + } //end-if + }//end-while + ldpp_dout(dpp, 10) << "D4NFilterObject::" << __func__ << "(): Thread exiting" << dendl; +} + int LRUPolicy::exist_key(const std::string& key) { const std::lock_guard l(lru_lock); diff --git a/src/rgw/driver/d4n/d4n_policy.h b/src/rgw/driver/d4n/d4n_policy.h index c51dd8e0d3f..c3ca641b3cb 100644 --- a/src/rgw/driver/d4n/d4n_policy.h +++ b/src/rgw/driver/d4n/d4n_policy.h @@ -38,6 +38,7 @@ class CachePolicy { std::string version; bool dirty; uint64_t refcount{0}; + Entry() = default; Entry(const std::string& key, uint64_t offset, uint64_t len, const std::string& version, bool dirty, uint64_t refcount) : key(key), offset(offset), len(len), version(version), dirty(dirty), refcount(refcount) {} }; @@ -120,7 +121,7 @@ class LFUDAPolicy : public CachePolicy { int localWeight; using handle_type = boost::heap::fibonacci_heap>>::handle_type; handle_type handle; - + LFUDAEntry() = default; LFUDAEntry(const std::string& key, uint64_t offset, uint64_t len, const std::string& version, bool dirty, uint64_t refcount, int localWeight) : Entry(key, offset, len, version, dirty, refcount), localWeight(localWeight) {} @@ -149,7 +150,9 @@ class LFUDAPolicy : public CachePolicy { std::mutex lfuda_cleaning_lock; std::condition_variable cond; std::condition_variable state_cond; + std::condition_variable lw_cond; inline static std::atomic quit{false}; + inline static std::atomic lw_quit{false}; int age = 1, weightSum = 0, postedSum = 0; optional_yield y = null_yield; @@ -161,6 +164,10 @@ class LFUDAPolicy : public CachePolicy { std::optional rthread_timer; rgw::sal::Driver* driver; std::thread tc; + std::thread lwthread; + //data structure for accumulating updated blocks + std::unordered_map updated_blocks; + static constexpr size_t LOCALWEIGHT_BATCH_SIZE = 10000; CacheBlock* get_victim_block(const DoutPrefixProvider* dpp, optional_yield y); int age_sync(const DoutPrefixProvider* dpp, optional_yield y); @@ -197,15 +204,18 @@ class LFUDAPolicy : public CachePolicy { delete blockDir; delete objDir; quit = true; + lw_quit = true; cond.notify_all(); + lw_cond.notify_all(); if (tc.joinable()) { tc.join(); } + if (lwthread.joinable()) { lwthread.join(); } for (auto& it : entries_map) { delete it.second; } for (auto& it : o_entries_map) { delete it.second.first; } - } + } virtual int init(CephContext *cct, const DoutPrefixProvider* dpp, asio::io_context& io_context, rgw::sal::Driver *_driver); virtual int exist_key(const std::string& key) override; @@ -228,6 +238,7 @@ class LFUDAPolicy : public CachePolicy { return it->second.first; } void save_y(optional_yield y) { this->y = y; } + void localweight_writer(const DoutPrefixProvider* dpp); }; class LRUPolicy : public CachePolicy { From 5910070aaba8daefbdee0dd4c2e62c8b4487bcbc Mon Sep 17 00:00:00 2001 From: Samarah Uriarte Date: Thu, 11 Jun 2026 17:48:42 +0000 Subject: [PATCH 245/596] rgw/d4n: Update policy unit test Signed-off-by: Samarah Uriarte --- src/test/rgw/test_d4n_policy.cc | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/test/rgw/test_d4n_policy.cc b/src/test/rgw/test_d4n_policy.cc index 467a0dfdd31..0029847357b 100644 --- a/src/test/rgw/test_d4n_policy.cc +++ b/src/test/rgw/test_d4n_policy.cc @@ -114,7 +114,8 @@ class LFUDAPolicyFixture : public ::testing::Test { std::string oid = rgw::sal::get_key_in_cache(get_prefix(block->cacheObj.bucketName, block->cacheObj.objName, version), std::to_string(block->blockID), std::to_string(block->size)); if (this->policyDriver->get_cache_policy()->exist_key(oid)) { /* Local copy */ - policyDriver->get_cache_policy()->update(env->dpp, oid, 0, TEST_DATA_LENGTH, "", false, rgw::d4n::RefCount::NOOP, y); + policyDriver->get_cache_policy()->update(env->dpp, oid, 0, TEST_DATA_LENGTH, "", std::nullopt, rgw::d4n::RefCount::NOOP, y); + // The local weight will not update until the calls exceed 10000 in total return 0; } else { if (this->policyDriver->get_cache_policy()->eviction(dpp, block->size, y) < 0) @@ -259,7 +260,7 @@ void rethrow(std::exception_ptr eptr) { TEST_F(LFUDAPolicyFixture, LocalGetBlockYield) { boost::asio::spawn(io, [this] (boost::asio::yield_context yield) { - env->cct->_conf->rgw_lfuda_sync_frequency = 1; + env->cct->_conf->rgw_lfuda_sync_frequency = 6; dynamic_cast(policyDriver->get_cache_policy())->save_y(optional_yield{yield}); policyDriver->get_cache_policy()->init(env->cct, env->dpp, io, driver); @@ -270,19 +271,22 @@ TEST_F(LFUDAPolicyFixture, LocalGetBlockYield) ASSERT_EQ(lfuda(env->dpp, block, cacheDriver, yield), 0); + boost::asio::steady_timer timer(io); + timer.expires_after(std::chrono::seconds(5)); + boost::system::error_code timer_ec; + timer.async_wait(yield[timer_ec]); + cacheDriver->shutdown(); boost::system::error_code ec; request req; - req.push("HGET", "RedisCache/testBucket#testName#0#9", RGW_CACHE_ATTR_LOCAL_WEIGHT); req.push("FLUSHALL"); - response resp; + response resp; conn->async_exec(req, resp, yield[ec]); ASSERT_EQ((bool)ec, false); - EXPECT_EQ(std::get<0>(resp).value(), "2"); conn->cancel(); delete policyDriver; From 59afb3d661ef6bc251bb65652032997d71844cee Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 16:24:16 +0800 Subject: [PATCH 246/596] rocksdb: update submodule to fix FTBFS due to missing 43dd4cbd370 bumped the rocksdb submodule to v7.10.2 for CVE-2022-23476, dropping the includes the v7.9.2 pin carried. db/blob/blob_file_meta.h uses uint64_t but no longer includes , so it compiles only where another header pulls in transitively. GCC with libstdc++ 16.1.0 no longer does, so the build fails: db/blob/blob_file_meta.h: error: 'uint64_t' has not been declared our targeted distros still pull it in, so the failure went unnoticed there: ubuntu jammy (GCC 11.2.0) and noble (GCC 13.2). bump the submodule to a cherry-pick of upstream rocksdb 72c3887167, which fixes the same FTBFS. Signed-off-by: Kefu Chai --- src/rocksdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rocksdb b/src/rocksdb index 0bd97e703ec..af6e913d159 160000 --- a/src/rocksdb +++ b/src/rocksdb @@ -1 +1 @@ -Subproject commit 0bd97e703ec62ad602717482508b08dd91baa8f5 +Subproject commit af6e913d15956f15ab888d1d0eb13120f462acba From a8af36d04e0e7979188468a0ab5d2b31dba3465a Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Fri, 12 Jun 2026 15:39:31 +0800 Subject: [PATCH 247/596] crimson/os/seastore/transaction: clear copied_lba_keys on reset Before this commit, Transaction::copied_lba_keys was never cleared on reset, so when a transaction is reset, and another rewrite transaction is committing, the rewrite transaction would try to update the copied lba mapping which may not have been copied by the reset transaction, which would result in unexpected errors. This commit generally follows the regulations about resetting transactions, which is clear everything on reset Fixes: https://tracker.ceph.com/issues/76945 Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/transaction.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/crimson/os/seastore/transaction.h b/src/crimson/os/seastore/transaction.h index a6e2e1c7272..c8c727e9a6f 100644 --- a/src/crimson/os/seastore/transaction.h +++ b/src/crimson/os/seastore/transaction.h @@ -535,6 +535,8 @@ public: } get_handle().exit(); views.clear(); + copied_lba_keys.clear(); + update_copied_lba_key = nullptr; } bool did_reset() const { From cdb37469b09e178b78c0ca161d0073f0842c034b Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sun, 14 Jun 2026 16:47:59 +0800 Subject: [PATCH 248/596] test/objectstore: hold split_blob cache shards in unique_ptr ExtentMap.split_blob, added in be93e121a98, creates the onode and buffer cache shards as raw pointers and never frees them. once we build with ASan, LeakSanitizer reports them (and the structures they own): Direct leak of 9928 byte(s) ... BlueStore::OnodeCacheShard::create Direct leak of 224 byte(s) ... BlueStore::BufferCacheShard::create SUMMARY: AddressSanitizer: 10288 byte(s) leaked in 8 allocation(s). every other test in this file already wraps these shards in a unique_ptr, declared before the collection that borrows them so they outlive it. do the same here. Signed-off-by: Kefu Chai --- src/test/objectstore/test_bluestore_types.cc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/objectstore/test_bluestore_types.cc b/src/test/objectstore/test_bluestore_types.cc index 23f7b847f83..c2025a25e1f 100644 --- a/src/test/objectstore/test_bluestore_types.cc +++ b/src/test/objectstore/test_bluestore_types.cc @@ -1078,12 +1078,12 @@ TEST(Blob, split) { TEST(ExtentMap, split_blob) { BlueStore store(g_ceph_context, "", 4096); - BlueStore::OnodeCacheShard *oc = - BlueStore::OnodeCacheShard::create(g_ceph_context, "lru", NULL); - BlueStore::BufferCacheShard *bc = - BlueStore::BufferCacheShard::create(&store, "lru", NULL); + std::unique_ptr oc{ + BlueStore::OnodeCacheShard::create(g_ceph_context, "lru", NULL)}; + std::unique_ptr bc{ + BlueStore::BufferCacheShard::create(&store, "lru", NULL)}; - auto coll = ceph::make_ref(&store, oc, bc, coll_t()); + auto coll = ceph::make_ref(&store, oc.get(), bc.get(), coll_t()); BlueStore::Onode onode(coll.get(), ghobject_t(), ""); size_t shard_size = g_ceph_context->_conf->bluestore_extent_map_inline_shard_prealloc_size; From 45f943ddb89f0913c58841a895c807f94354a45c Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Thu, 11 Jun 2026 18:51:33 +0530 Subject: [PATCH 249/596] mgr/smb: Add client support mode for macOS-specific SMB features This commit introduces a new cluster-level configuration option to enable client-specific SMB optimizations, starting with macOS support. Usage: ceph smb cluster create --client-compat macos ceph smb cluster update client-compat macos Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/enums.py | 13 ++++++ src/pybind/mgr/smb/handler.py | 18 ++++++++- src/pybind/mgr/smb/module.py | 72 +++++++++++++++++++++++++++++++++ src/pybind/mgr/smb/resources.py | 21 ++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/smb/enums.py b/src/pybind/mgr/smb/enums.py index 698cb6dde02..fac7ba54bbf 100644 --- a/src/pybind/mgr/smb/enums.py +++ b/src/pybind/mgr/smb/enums.py @@ -124,6 +124,19 @@ class ShowResults(_StrEnum): COLLAPSED = 'collapsed' +class ClientSupportMode(_StrEnum): + """Determines if client-specific SMB features should be enabled. + + - DEFAULT: Standard SMB behavior without client-specific optimizations + - MACOS: Enable macOS-specific features (AAPL extensions, fruit VFS, etc.) + + Future values could include: WINDOWS_OPTIMIZED, LINUX_OPTIMIZED, etc. + """ + + DEFAULT = 'default' + MACOS = 'macos' + + class PasswordFilter(_StrEnum): """Filter type for password values.""" diff --git a/src/pybind/mgr/smb/handler.py b/src/pybind/mgr/smb/handler.py index f376368bd6d..6f6954d0b2e 100644 --- a/src/pybind/mgr/smb/handler.py +++ b/src/pybind/mgr/smb/handler.py @@ -759,6 +759,7 @@ def order_resources( @dataclasses.dataclass(frozen=True) class _ShareConf: resource: resources.Share + cluster: resources.Cluster resolver: PathResolver cephx_entity: str ceph_cluster: str @@ -807,7 +808,13 @@ class _ClusterConf: return cls( change_group.cluster, [ - _ShareConf(s, resolver, cephx_entity, ceph_cluster) + _ShareConf( + s, + change_group.cluster, + resolver, + cephx_entity, + ceph_cluster, + ) for s in change_group.shares ], change_group, @@ -847,7 +854,14 @@ def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]: if conf.ceph_cluster else '/etc/ceph/ceph.conf' ) - modules = ["acl_xattr", "ceph_snapshots"] + # Build VFS modules list based on cluster configuration + modules = [] + # Add macOS support modules if enabled + if conf.cluster.is_macos_compatibility_enabled: + modules.extend(["fruit", "streams_xattr"]) + + # Add standard modules + modules.extend(["acl_xattr", "ceph_snapshots"]) if qos := cephfs.qos: vfs_rl = "aio_ratelimit" diff --git a/src/pybind/mgr/smb/module.py b/src/pybind/mgr/smb/module.py index 4f69eb9b2e5..9badbe57460 100644 --- a/src/pybind/mgr/smb/module.py +++ b/src/pybind/mgr/smb/module.py @@ -31,6 +31,7 @@ from . import ( from .cli import SMBCLICommand from .enums import ( AuthMode, + ClientSupportMode, InputPasswordFilter, JoinSourceType, PasswordFilter, @@ -223,6 +224,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): placement: Optional[str] = None, clustering: Optional[SMBClustering] = None, public_addrs: Optional[List[str]] = None, + client_compat: Optional[ClientSupportMode] = None, password_filter: InputPasswordFilter = InputPasswordFilter.NONE, password_filter_out: Optional[PasswordFilter] = None, ) -> results.Result: @@ -333,6 +335,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): placement=pspec, clustering=clustering, public_addrs=c_public_addrs, + client_compat=client_compat, ) to_apply.append(cluster) return self._apply_res( @@ -442,6 +445,75 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): password_filter_out=password_filter, ) + @SMBCLICommand('cluster update client-compat', perm='rw') + def cluster_update_client_compat( + self, + client_compat: ClientSupportMode, + cluster_id: str, + ) -> Simplified: + """Update client compatibility mode for an SMB cluster and all its shares""" + # Get the existing cluster + clusters = self._handler.matching_resources( + [f'ceph.smb.cluster.{cluster_id}'] + ) + + active_clusters = [ + c for c in clusters if isinstance(c, resources.Cluster) + ] + + if not active_clusters: + raise ValueError(f"Cluster {cluster_id} not found") + + if len(active_clusters) > 1: + raise ValueError(f"Multiple clusters found matching {cluster_id}") + + cluster = active_clusters[0] + + # Create updated cluster with new client_compat setting + updated_cluster = replace(cluster, client_compat=client_compat) + + # Get all shares for this cluster + shares = self._handler.matching_resources( + [f'ceph.smb.share.{cluster_id}'] + ) + + active_shares = [s for s in shares if isinstance(s, resources.Share)] + + # Prepare resources to update: cluster + all shares + resources_to_update: List[resources.SMBResource] = [updated_cluster] + resources_to_update.extend(active_shares) + + # Apply the updates + result_group = self._apply_res(resources_to_update) + + # Process results + cluster_updated = False + successful_share_updates = [] + failed_share_updates = [] + + for result in result_group: + if result.success: + if isinstance(result.src, resources.Cluster): + cluster_updated = True + elif hasattr(result.src, 'share_id'): + successful_share_updates.append(result.src.share_id) + else: + if isinstance(result.src, resources.Share) and hasattr( + result.src, 'share_id' + ): + failed_share_updates.append( + {"share_id": result.src.share_id, "error": result.msg} + ) + + return { + "cluster_id": cluster_id, + "client_compat": client_compat.value, + "cluster_updated": cluster_updated, + "successful_share_updates": successful_share_updates, + "failed_share_updates": failed_share_updates, + "total_shares": len(active_shares), + } + @SMBCLICommand('cluster update cephfs qos', perm='rw') def cluster_update_qos( self, diff --git a/src/pybind/mgr/smb/resources.py b/src/pybind/mgr/smb/resources.py index fec79e8b102..0128b6346d2 100644 --- a/src/pybind/mgr/smb/resources.py +++ b/src/pybind/mgr/smb/resources.py @@ -29,6 +29,7 @@ from . import resourcelib, validation from .enums import ( AuthMode, CephFSStorageProvider, + ClientSupportMode, HostAccess, Intent, JoinSourceType, @@ -902,6 +903,8 @@ class Cluster(_RBase): debug_level: Optional[dict[str, str]] = None # configure the keybridge (KMS integration) for this cluster keybridge: Optional[KeyBridge] = None + # client support mode for client-specific optimizations (macOS, etc.) + client_compat: Optional[ClientSupportMode] = None def validate(self) -> None: if not self.cluster_id: @@ -950,6 +953,24 @@ class Cluster(_RBase): def clustering_mode(self) -> SMBClustering: return self.clustering if self.clustering else SMBClustering.DEFAULT + @property + def effective_client_compat(self) -> ClientSupportMode: + """Return the effective client compat mode. + + Returns ClientSupportMode.DEFAULT if not explicitly set, ensuring + client-specific features are disabled by default. + """ + return ( + self.client_compat + if self.client_compat + else ClientSupportMode.DEFAULT + ) + + @property + def is_macos_compatibility_enabled(self) -> bool: + """Return true if macOS-specific SMB features should be enabled.""" + return self.effective_client_compat == ClientSupportMode.MACOS + @property def remote_control_is_enabled(self) -> bool: """Return true if a remote control service should be enabled for this From 6f59987690de84158f32b83b911f4785b4b8073a Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Fri, 12 Jun 2026 16:59:22 +0530 Subject: [PATCH 250/596] mgr/smb: add tests for client compatibility mode Add comprehensive test coverage for the new client compatibility feature that enables macOS-specific SMB optimizations: - test_enums.py: Add tests for ClientSupportMode enum values (DEFAULT and MACOS) and string representation - test_resources.py: Add tests for cluster client_compat field, effective_client_compat property, and is_macos_compatibility_enabled property with different mode configurations - test_smb.py: Add integration tests for cluster_update_client_compat CLI command including successful updates and error handling for non-existent clusters These tests ensure the client compatibility mode can be properly set, retrieved, and updated at the cluster level. Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/tests/test_enums.py | 20 +++++ src/pybind/mgr/smb/tests/test_resources.py | 83 +++++++++++++++++ src/pybind/mgr/smb/tests/test_smb.py | 100 +++++++++++++++++++++ 3 files changed, 203 insertions(+) diff --git a/src/pybind/mgr/smb/tests/test_enums.py b/src/pybind/mgr/smb/tests/test_enums.py index 1ebd40d238c..641d6593a46 100644 --- a/src/pybind/mgr/smb/tests/test_enums.py +++ b/src/pybind/mgr/smb/tests/test_enums.py @@ -43,3 +43,23 @@ def test_login_access_expand(): == smb.enums.LoginAccess.READ_WRITE ) assert smb.enums.LoginAccess.NONE.expand() == smb.enums.LoginAccess.NONE + + +def test_client_compat_enum(): + """Test ClientSupportMode enum values and string representation.""" + assert smb.enums.ClientSupportMode.DEFAULT == 'default' + assert smb.enums.ClientSupportMode.MACOS == 'macos' + assert str(smb.enums.ClientSupportMode.DEFAULT) == 'default' + assert str(smb.enums.ClientSupportMode.MACOS) == 'macos' + + +def test_client_compat_values(): + """Test that ClientSupportMode can be constructed from string values.""" + assert ( + smb.enums.ClientSupportMode('default') + == smb.enums.ClientSupportMode.DEFAULT + ) + assert ( + smb.enums.ClientSupportMode('macos') + == smb.enums.ClientSupportMode.MACOS + ) diff --git a/src/pybind/mgr/smb/tests/test_resources.py b/src/pybind/mgr/smb/tests/test_resources.py index cb90dd47fba..a9987b2a7ce 100644 --- a/src/pybind/mgr/smb/tests/test_resources.py +++ b/src/pybind/mgr/smb/tests/test_resources.py @@ -750,6 +750,89 @@ login_control: assert share.login_control[3].access == enums.LoginAccess.NONE +def test_cluster_client_compat_default(): + """Test cluster with default client support mode (not set).""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.cluster +cluster_id: testcluster +auth_mode: user +user_group_settings: + - source_type: resource + ref: testusers +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + cluster = loaded[0] + + # When not set, should default to None + assert cluster.client_compat is None + # effective_client_compat should return DEFAULT + assert cluster.effective_client_compat == enums.ClientSupportMode.DEFAULT + # is_macos_compatibility_enabled should be False + assert cluster.is_macos_compatibility_enabled is False + + +def test_cluster_client_compat_macos(): + """Test cluster with macos client support mode enabled.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.cluster +cluster_id: maccluster +auth_mode: user +user_group_settings: + - source_type: resource + ref: macusers +client_compat: macos +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + cluster = loaded[0] + + # Should be set to MACOS + assert cluster.client_compat == enums.ClientSupportMode.MACOS + # effective_client_compat should return MACOS + assert cluster.effective_client_compat == enums.ClientSupportMode.MACOS + # is_macos_compatibility_enabled should be True + assert cluster.is_macos_compatibility_enabled is True + + # Verify it's in the simplified output + sd = cluster.to_simplified() + assert sd + assert 'client_compat' in sd + assert sd['client_compat'] == 'macos' + + +def test_cluster_client_compat_explicit_default(): + """Test cluster with explicitly set default client support mode.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.cluster +cluster_id: defaultcluster +auth_mode: user +user_group_settings: + - source_type: resource + ref: defaultusers +client_compat: default +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + cluster = loaded[0] + + # Should be explicitly set to DEFAULT + assert cluster.client_compat == enums.ClientSupportMode.DEFAULT + # effective_client_compat should return DEFAULT + assert cluster.effective_client_compat == enums.ClientSupportMode.DEFAULT + # is_macos_compatibility_enabled should be False + assert cluster.is_macos_compatibility_enabled is False + + def test_tls_credential(): import yaml diff --git a/src/pybind/mgr/smb/tests/test_smb.py b/src/pybind/mgr/smb/tests/test_smb.py index 54f9fb19fae..1d5245af6ea 100644 --- a/src/pybind/mgr/smb/tests/test_smb.py +++ b/src/pybind/mgr/smb/tests/test_smb.py @@ -1324,3 +1324,103 @@ def test_cluster_rm_wildcard_no_match(tmodule): with pytest.raises(smb.cli.NoMatchingValue): tmodule.cluster_rm('gonk', wildcard=True) + + +def test_cluster_update_client_compat(tmodule): + """Test updating cluster client compatibility mode with shares.""" + # Create a cluster with default client_compat + cluster = _cluster( + cluster_id='foo', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + rg = tmodule._handler.apply([cluster]) + assert rg.success, rg.to_simplified() + + # Create some shares + share1 = smb.resources.Share( + cluster_id='foo', + share_id='share1', + cephfs=smb.resources.CephFSStorage( + volume='cephfs', + path='/share1', + ), + ) + share2 = smb.resources.Share( + cluster_id='foo', + share_id='share2', + cephfs=smb.resources.CephFSStorage( + volume='cephfs', + path='/share2', + ), + ) + rg = tmodule._handler.apply([share1, share2]) + assert rg.success, rg.to_simplified() + + # Verify initial state (should be None/DEFAULT) + clusters = tmodule._handler.matching_resources(['ceph.smb.cluster.foo']) + assert len(clusters) == 1 + initial_cluster = clusters[0] + assert initial_cluster.client_compat is None + assert ( + initial_cluster.effective_client_compat + == smb.enums.ClientSupportMode.DEFAULT + ) + + # Update to MACOS compatibility mode + result = tmodule.cluster_update_client_compat( + smb.enums.ClientSupportMode.MACOS, 'foo' + ) + assert isinstance(result, dict) + assert result['cluster_id'] == 'foo' + assert result['client_compat'] == 'macos' + assert result['cluster_updated'] is True + assert result['total_shares'] == 2 + assert len(result['successful_share_updates']) == 2 + assert 'share1' in result['successful_share_updates'] + assert 'share2' in result['successful_share_updates'] + assert len(result['failed_share_updates']) == 0 + + # Verify the update + clusters = tmodule._handler.matching_resources(['ceph.smb.cluster.foo']) + assert len(clusters) == 1 + updated_cluster = clusters[0] + assert updated_cluster.client_compat == smb.enums.ClientSupportMode.MACOS + assert ( + updated_cluster.effective_client_compat + == smb.enums.ClientSupportMode.MACOS + ) + assert updated_cluster.is_macos_compatibility_enabled is True + + # Update back to DEFAULT + result = tmodule.cluster_update_client_compat( + smb.enums.ClientSupportMode.DEFAULT, 'foo' + ) + assert isinstance(result, dict) + assert result['cluster_id'] == 'foo' + assert result['client_compat'] == 'default' + assert result['cluster_updated'] is True + assert result['total_shares'] == 2 + + # Verify the update back to DEFAULT + clusters = tmodule._handler.matching_resources(['ceph.smb.cluster.foo']) + assert len(clusters) == 1 + final_cluster = clusters[0] + assert final_cluster.client_compat == smb.enums.ClientSupportMode.DEFAULT + assert ( + final_cluster.effective_client_compat + == smb.enums.ClientSupportMode.DEFAULT + ) + assert final_cluster.is_macos_compatibility_enabled is False + + +def test_cluster_update_client_compat_nonexistent(tmodule): + """Test updating client_compat for a non-existent cluster.""" + with pytest.raises(ValueError, match="Cluster nonexistent not found"): + tmodule.cluster_update_client_compat( + smb.enums.ClientSupportMode.MACOS, 'nonexistent' + ) From 2bb17b80aa4c5813b6a8fc78fc0e2410b746a76b Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 14:13:22 +0800 Subject: [PATCH 251/596] run-make-check.sh: build ctest cpu resource pool from sched_getaffinity gen_ctest_resource_file assumed online CPUs are a contiguous 0..nproc-1 range. When a middle core is hot-offlined (e.g. for a hardware fault), that both hands out the offline cpu id and drops an online one; crimson seastar unittests feed the ctest resource id straight to seastar --cpuset, so hitting the offline id aborts with "Bad value for --cpuset: N not allowed". Use the CPUs actually schedulable by the process via sched_getaffinity(). Signed-off-by: Sun Yuechi --- run-make-check.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/run-make-check.sh b/run-make-check.sh index 264cac65c18..2c420f3dad1 100755 --- a/run-make-check.sh +++ b/run-make-check.sh @@ -24,9 +24,12 @@ set -e function gen_ctest_resource_file() { local file_name=$(mktemp /tmp/ctest-resource-XXXXXX) - local max_cpuid=$(($(nproc) - 1)) + # Usable CPU ids for this process: excludes offline cores and honors cgroup/ + # taskset limits. Not necessarily contiguous 0..nproc-1, so a crimson seastar + # unittest could otherwise get an offline id in --cpuset and abort. + local ids=$(python3 -c 'import os; print(*sorted(os.sched_getaffinity(0)))') jq -n '$ARGS.positional | map({id:., slots:1}) | {cpus:.} | {version: {major:1, minor:0}, local:[.]}' \ - --args $(seq 0 $max_cpuid) > $file_name + --args $ids > $file_name echo "$file_name" } From 82ab9cc2e3609cf0a172b3f322ac4420316c8642 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 04:10:15 +0800 Subject: [PATCH 252/596] crimson/cmake: restrict -Wno-non-virtual-dtor to C++ sources The seastar target exports -Wno-non-virtual-dtor as a PUBLIC compile option, so it leaks to every target that links seastar, including C sources such as crimson-common's reverse.c, producing: cc1: warning: command-line option '-Wno-non-virtual-dtor' is valid for C++/ObjC++ but not for C Guard the option with $ so that it only applies to C++ compilations. Signed-off-by: Sun Yuechi --- src/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a93a38d7501..b5fd500e8ea 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -502,7 +502,7 @@ if(WITH_CRIMSON) target_compile_options(seastar PUBLIC # required by any target that links to seastar - "-Wno-non-virtual-dtor" + "$<$:-Wno-non-virtual-dtor>" PRIVATE "-DSEASTAR_NO_EXCEPTION_HACK" "-Wno-error" From d4fc8b65b71239d6514e4389a06a927bb2a2d635 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 16 Jun 2026 04:10:52 +0800 Subject: [PATCH 253/596] pybind/cephfs: drop redundant int32_t typedef int32_t is already provided by "from libc.stdint cimport *", so the extra ctypedef in the platform_errno.h extern block redeclares it: warning: c_cephfs.pxd:29:4: 'int32_t' redeclared Fixes: https://tracker.ceph.com/issues/77440 Signed-off-by: Sun Yuechi --- src/pybind/cephfs/c_cephfs.pxd | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pybind/cephfs/c_cephfs.pxd b/src/pybind/cephfs/c_cephfs.pxd index 9d5b93c0493..603906046a4 100644 --- a/src/pybind/cephfs/c_cephfs.pxd +++ b/src/pybind/cephfs/c_cephfs.pxd @@ -26,7 +26,6 @@ cdef extern from *: unsigned long DIRENT_D_OFF(dirent *d) cdef extern from "../include/platform_errno.h": - ctypedef signed int int32_t; int32_t ceph_to_hostos_errno(int32_t e) cdef extern from "cephfs/ceph_ll_client.h": From 620d9362f24360aca966a6ee0817122ea71b70e3 Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Mon, 15 Jun 2026 11:24:18 +0000 Subject: [PATCH 254/596] crimson/os/seastore: fix laddr_t formatter and its use 'laddr_t' existing formatter did not support a ':x' format specifier (actually - the output was always hexadecomal). Here we remove the ':x', but also refactor the custom formatter to avoid using the streambuf mechanism. Note - SEASTORE_LADDR_USE_BOOST_U128 is no longer supported by the formatter. Fixes: https://tracker.ceph.com/issues/77399 Signed-off-by: Ronen Friedman --- .../os/seastore/object_data_handler.cc | 2 +- src/crimson/os/seastore/seastore_types.h | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/crimson/os/seastore/object_data_handler.cc b/src/crimson/os/seastore/object_data_handler.cc index 531aaebd233..a586a606d68 100644 --- a/src/crimson/os/seastore/object_data_handler.cc +++ b/src/crimson/os/seastore/object_data_handler.cc @@ -1442,7 +1442,7 @@ ObjectDataHandler::clear_ret ObjectDataHandler::trim_data_reservation( context_t ctx, object_data_t &object_data, extent_len_t size) { LOG_PREFIX(ObjectDataHandler::trim_data_reservation); - DEBUGT("0x{:x}~0x{:x}, 0x{:x}", + DEBUGT("{}~0x{:x}, 0x{:x}", ctx.t, object_data.get_reserved_data_base(), object_data.get_reserved_data_len(), size); ceph_assert(!object_data.is_null()); diff --git a/src/crimson/os/seastore/seastore_types.h b/src/crimson/os/seastore/seastore_types.h index 6f29c9c9b70..082baeacea3 100644 --- a/src/crimson/os/seastore/seastore_types.h +++ b/src/crimson/os/seastore/seastore_types.h @@ -1487,6 +1487,7 @@ public: } friend std::ostream &operator<<(std::ostream &, const laddr_t &); + friend struct fmt::formatter; friend bool operator==(const laddr_t&, const laddr_t&) = default; friend bool operator==(const laddr_t &laddr, const laddr_offset_t &laddr_offset) { @@ -3622,7 +3623,6 @@ template <> struct fmt::formatter : fmt:: template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; -template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; @@ -3652,6 +3652,42 @@ template <> struct fmt::formatter : fmt::ost template <> struct fmt::formatter : fmt::ostream_formatter {}; #endif +namespace fmt { +template <> +struct formatter { + constexpr auto + parse(format_parse_context& ctx) + { + return ctx.begin(); + } + + template + auto + format(const crimson::os::seastore::laddr_t& laddr, FormatContext& ctx) const + { + if (laddr == crimson::os::seastore::L_ADDR_NULL) { + return fmt::format_to(ctx.out(), "L_ADDR_NULL"); + } + + fmt::format_to(ctx.out(), "L0x{:x}", laddr.value); + if (!laddr.is_global_address()) { + fmt::format_to( + ctx.out(), "({:x},{:x},{:x}", static_cast(laddr.get_shard()), + laddr.get_pool(), laddr.get_reversed_hash()); + if (laddr.is_object_address()) { + fmt::format_to( + ctx.out(), ",{:x},{:x},{:x},{:x}", laddr.get_local_object_id(), + laddr.get_local_clone_id(), static_cast(laddr.is_metadata()), + laddr.get_offset_bytes()); + } + fmt::format_to(ctx.out(), ")"); + } + return ctx.out(); + } +}; + +} // namespace fmt + template <> struct std::hash { using Laddr = crimson::os::seastore::laddr_t; From c183e27deac5ede7c1614f453b0ab0d03fa415f8 Mon Sep 17 00:00:00 2001 From: Radoslaw Zarzynski Date: Tue, 16 Jun 2026 16:02:22 +0000 Subject: [PATCH 255/596] tests/neorados: fix ceph_test_neorados_completions being not installed Fixes: https://tracker.ceph.com/issues/77417 Signed-off-by: Radoslaw Zarzynski --- src/test/neorados/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/neorados/CMakeLists.txt b/src/test/neorados/CMakeLists.txt index e10672e3a57..3f623bbd64e 100644 --- a/src/test/neorados/CMakeLists.txt +++ b/src/test/neorados/CMakeLists.txt @@ -21,6 +21,9 @@ target_link_libraries(neoradostest-support add_executable(ceph_test_neorados_completions completions.cc) target_link_libraries(ceph_test_neorados_completions libneorados neoradostest-support GTest::Main) +install(TARGETS + ceph_test_neorados_completions + DESTINATION ${CMAKE_INSTALL_BINDIR}) add_executable(ceph_test_neorados_list_pool list_pool.cc) target_link_libraries(ceph_test_neorados_list_pool From b8310f33c377b4c3e3f43026184f5fdd741f6a8e Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 22:08:15 +0530 Subject: [PATCH 256/596] tools/cephfs_mirror: Add per-peer tick thread with configurable interval Introduce a per-peer tick thread controlled by cephfs_mirror_tick_interval (default 5 seconds). The interval is re-read each iteration so configuration changes take effect without restarting the daemon. The thread provides a generic hook for future periodic mirroring work. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/common/options/cephfs-mirror.yaml.in | 11 ++++++++++ src/tools/cephfs_mirror/PeerReplayer.cc | 26 ++++++++++++++++++++++++ src/tools/cephfs_mirror/PeerReplayer.h | 17 ++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/common/options/cephfs-mirror.yaml.in b/src/common/options/cephfs-mirror.yaml.in index b009c0a8d6f..399076cda0b 100644 --- a/src/common/options/cephfs-mirror.yaml.in +++ b/src/common/options/cephfs-mirror.yaml.in @@ -104,6 +104,17 @@ options: services: - cephfs-mirror min: 1 +- name: cephfs_mirror_tick_interval + type: secs + level: advanced + desc: interval for the per-peer mirroring tick thread + long_desc: interval in seconds for the per-peer tick thread that runs periodic + mirroring work. The value is re-read each iteration so configuration changes + take effect without restarting the daemon. + default: 5 + services: + - cephfs-mirror + min: 1 - name: cephfs_mirror_max_consecutive_failures_per_directory type: uint level: advanced diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 93bc04ddaf8..4bd4681f0e0 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -310,6 +310,12 @@ int PeerReplayer::init() { data_replayer->create(name.c_str()); m_data_replayers.push_back(std::move(data_replayer)); } + + m_tick_thread.reset(new TickThread(this)); + m_tick_thread->create("tick"); + + set_changed_mirroring_configurations(); + return 0; } @@ -349,6 +355,11 @@ void PeerReplayer::shutdown() { } m_replayers.clear(); + if (m_tick_thread) { + m_tick_thread->join(); + m_tick_thread.reset(); + } + ceph_unmount(m_remote_mount); ceph_release(m_remote_mount); m_remote_mount = nullptr; @@ -2336,6 +2347,21 @@ int PeerReplayer::sync_snaps(const std::string &dir_root, return r; } +void PeerReplayer::run_tick() { + dout(10) << ": started" << dendl; + + std::unique_lock locker(m_lock); + while (true) { + auto interval = g_ceph_context->_conf.get_val( + "cephfs_mirror_tick_interval"); + m_cond.wait_for(locker, interval, [this]{return is_stopping();}); + if (is_stopping()) { + dout(5) << ": shutting down exiting" << dendl; + break; + } + } +} + void PeerReplayer::run(SnapshotReplayerThread *replayer) { dout(10) << ": snapshot replayer=" << replayer << dendl; diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index abe95c6dfe8..e91c5b495b2 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -129,6 +129,21 @@ private: PeerReplayer *m_peer_replayer; }; + class TickThread : public Thread { + public: + explicit TickThread(PeerReplayer *peer_replayer) + : m_peer_replayer(peer_replayer) { + } + + void *entry() override { + m_peer_replayer->run_tick(); + return 0; + } + + private: + PeerReplayer *m_peer_replayer; + }; + struct DirRegistry { int fd; bool canceled = false; @@ -636,6 +651,7 @@ private: SnapshotReplayers m_replayers; SnapshotDataReplayers m_data_replayers; + std::unique_ptr m_tick_thread; std::atomic m_active_datasync_threads{0}; ceph::mutex smq_lock; @@ -652,6 +668,7 @@ private: void run(SnapshotReplayerThread *replayer); void run_datasync(SnapshotDataSyncThread *data_replayer); + void run_tick(); void remove_syncm(const std::shared_ptr& syncm_obj); bool is_syncm_active(const std::shared_ptr& syncm_obj); std::shared_ptr pick_next_syncm_and_mark(); From 16009741876c7943a8db3e5428550c5707b8d7af Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 22:52:21 +0530 Subject: [PATCH 257/596] tools/cephfs_mirror: Expose per-directory snap metrics via perf counters Introduce a new labeled perf counter group, cephfs_mirror_directory, so per-directory snapshot mirror progress can be scraped via "counter dump" and exported to Prometheus by ceph-exporter (e.g. ceph_cephfs_mirror_directory_current_sync_bytes). Design ------ * One PerfCounters instance per mirrored directory on a peer, keyed in m_directory_perf_counters and registered on the daemon-wide PerfCountersCollection. * Labels on each instance (flat counter dump array entries): - source_fscid, source_filesystem - peer_uuid, peer_cluster_name, peer_cluster_filesystem - directory (dir_root, e.g. "/parent/d1") The peer_uuid label disambiguates the same directory path mirrored to different peers. * Counters are created in init() and add_directory(), removed in remove_directory() and the PeerReplayer destructor. * Priority follows cephfs_mirror_perf_stats_prio (same as cephfs_mirror_peers). Update path ----------- Live / current_syncing_snap gauges are refreshed from update_directory_current_sync_perf_counters(), called by refresh_directory_current_sync_perf_counters() from the per-peer tick thread (run_tick()). Each cephfs_mirror_tick_interval seconds (default 5) the tick thread updates counters for each registered (actively syncing) directory. Counters registered (schema) ---------------------------- All of the following are added to the builder in this commit. Only the "current sync" and dir_state fields listed under "Updated in this commit" are written here; last_synced_snap and per-directory snap summary counters are registered for a follow-up commit that updates them when stats change. Directory state dir_state (gauge u64) 0 = idle, 1 = syncing, 2 = failed Maps peer_status top-level "state" (numeric; no string values). Current syncing snapshot (peer_status "current_syncing_snap") [Updated in this commit] current_snap_id - snapshot id being synchronized current_sync_mode - 0 = full, 1 = delta (snapdiff) current_read_bps - bytes/sec read (raw, not formatted) current_write_bps - bytes/sec written crawl_state - 0 = N/A, 1 = in-progress, 2 = completed crawl_duration_seconds - crawl duration; in-progress uses now - start datasync_wait_state - 0 = none, 1 = waiting, 2 = complete datasync_wait_duration_seconds current_sync_bytes - bytes synced so far for this snap current_total_bytes - total bytes for this snap current_sync_bytes_percent - basis points (1745 = 17.45%) current_sync_files current_total_files current_sync_files_percent - basis points current_eta_valid - 0 = calculating, 1 = ETA available current_eta_seconds - ETA in seconds when valid Per-directory snapshot summary (peer_status snaps_*) [Registered only; not updated in this commit] snaps_synced, snaps_deleted, snaps_renamed Last synced snapshot (peer_status "last_synced_snap") [Registered only; not updated in this commit] last_snap_id last_crawl_duration_seconds last_datasync_wait_duration_seconds last_sync_duration_seconds last_sync_timestamp - utime_t / seconds since epoch last_sync_bytes last_sync_files When idle or failed, current_* counters are zeroed and dir_state reflects 0 or 2 respectively. Fixes: https://tracker.ceph.com/issues/73457 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 271 ++++++++++++++++++++++++ src/tools/cephfs_mirror/PeerReplayer.h | 8 + 2 files changed, 279 insertions(+) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 4bd4681f0e0..d532b6a3adf 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -48,6 +48,38 @@ enum { l_cephfs_mirror_peer_replayer_last, }; +enum { + l_cephfs_mirror_directory_first = 7000, + l_cephfs_mirror_directory_dir_state, + l_cephfs_mirror_directory_current_snap_id, + l_cephfs_mirror_directory_current_sync_mode, + l_cephfs_mirror_directory_current_read_bps, + l_cephfs_mirror_directory_current_write_bps, + l_cephfs_mirror_directory_crawl_state, + l_cephfs_mirror_directory_crawl_duration_seconds, + l_cephfs_mirror_directory_datasync_wait_state, + l_cephfs_mirror_directory_datasync_wait_duration_seconds, + l_cephfs_mirror_directory_current_sync_bytes, + l_cephfs_mirror_directory_current_total_bytes, + l_cephfs_mirror_directory_current_sync_bytes_percent, + l_cephfs_mirror_directory_current_sync_files, + l_cephfs_mirror_directory_current_total_files, + l_cephfs_mirror_directory_current_sync_files_percent, + l_cephfs_mirror_directory_current_eta_valid, + l_cephfs_mirror_directory_current_eta_seconds, + l_cephfs_mirror_directory_snaps_synced, + l_cephfs_mirror_directory_snaps_deleted, + l_cephfs_mirror_directory_snaps_renamed, + l_cephfs_mirror_directory_last_snap_id, + l_cephfs_mirror_directory_last_crawl_duration_seconds, + l_cephfs_mirror_directory_last_datasync_wait_duration_seconds, + l_cephfs_mirror_directory_last_sync_duration_seconds, + l_cephfs_mirror_directory_last_sync_timestamp, + l_cephfs_mirror_directory_last_sync_bytes, + l_cephfs_mirror_directory_last_sync_files, + l_cephfs_mirror_directory_last, +}; + namespace cephfs { namespace mirror { @@ -221,6 +253,11 @@ PeerReplayer::PeerReplayer(CephContext *cct, FSMirror *fs_mirror, PeerReplayer::~PeerReplayer() { delete m_asok_hook; + for (auto &[dir_root, perf] : m_directory_perf_counters) { + m_cct->get_perfcounters_collection()->remove(perf); + delete perf; + } + m_directory_perf_counters.clear(); PerfCounters *perf_counters = nullptr; std::swap(perf_counters, m_perf_counters); if (perf_counters != nullptr) { @@ -229,11 +266,234 @@ PeerReplayer::~PeerReplayer() { } } +uint64_t percent_basis_points(uint64_t num, uint64_t den) { + if (den == 0) { + return 0; + } + return static_cast((static_cast(num) * 10000.0) / den); +} + +void PeerReplayer::create_directory_perf_counters(const std::string &dir_root) { + ceph_assert(m_directory_perf_counters.find(dir_root) == + m_directory_perf_counters.end()); + + std::string labels = ceph::perf_counters::key_create("cephfs_mirror_directory", { + {"source_fscid", stringify(m_filesystem.fscid)}, + {"source_filesystem", m_filesystem.fs_name}, + {"peer_uuid", m_peer.uuid}, + {"peer_cluster_name", m_peer.remote.cluster_name}, + {"peer_cluster_filesystem", m_peer.remote.fs_name}, + {"directory", dir_root}, + }); + PerfCountersBuilder plb(m_cct, labels, l_cephfs_mirror_directory_first, + l_cephfs_mirror_directory_last); + auto prio = m_cct->_conf.get_val("cephfs_mirror_perf_stats_prio"); + + plb.add_u64(l_cephfs_mirror_directory_dir_state, + "dir_state", "Directory mirror state", "dste", prio); + plb.add_u64(l_cephfs_mirror_directory_current_snap_id, + "current_snap_id", "Current syncing snapshot id", "csid", prio); + plb.add_u64(l_cephfs_mirror_directory_current_sync_mode, + "current_sync_mode", "Current sync mode", "csmd", prio); + plb.add_u64(l_cephfs_mirror_directory_current_read_bps, + "current_read_bps", "Current read throughput bytes per second", "crbp", prio); + plb.add_u64(l_cephfs_mirror_directory_current_write_bps, + "current_write_bps", "Current write throughput bytes per second", "cwbp", prio); + plb.add_u64(l_cephfs_mirror_directory_crawl_state, + "crawl_state", "Current crawl state", "crst", prio); + plb.add_u64(l_cephfs_mirror_directory_crawl_duration_seconds, + "crawl_duration_seconds", "Current crawl duration seconds", "crdn", prio); + plb.add_u64(l_cephfs_mirror_directory_datasync_wait_state, + "datasync_wait_state", "Current datasync queue wait state", "dwst", prio); + plb.add_u64(l_cephfs_mirror_directory_datasync_wait_duration_seconds, + "datasync_wait_duration_seconds", + "Current datasync queue wait duration seconds", "dwdn", prio); + plb.add_u64(l_cephfs_mirror_directory_current_sync_bytes, + "current_sync_bytes", "Current sync bytes", "csby", prio); + plb.add_u64(l_cephfs_mirror_directory_current_total_bytes, + "current_total_bytes", "Current total bytes", "ctby", prio); + plb.add_u64(l_cephfs_mirror_directory_current_sync_bytes_percent, + "current_sync_bytes_percent", + "Current sync bytes percent in basis points", "csbp", prio); + plb.add_u64(l_cephfs_mirror_directory_current_sync_files, + "current_sync_files", "Current sync files", "csfl", prio); + plb.add_u64(l_cephfs_mirror_directory_current_total_files, + "current_total_files", "Current total files", "ctfl", prio); + plb.add_u64(l_cephfs_mirror_directory_current_sync_files_percent, + "current_sync_files_percent", + "Current sync files percent in basis points", "csfp", prio); + plb.add_u64(l_cephfs_mirror_directory_current_eta_valid, + "current_eta_valid", "Current sync ETA validity", "cetv", prio); + plb.add_u64(l_cephfs_mirror_directory_current_eta_seconds, + "current_eta_seconds", "Current sync ETA seconds", "cets", prio); + plb.add_u64(l_cephfs_mirror_directory_snaps_synced, + "snaps_synced", "Snapshots synchronized", "ssnc", prio); + plb.add_u64(l_cephfs_mirror_directory_snaps_deleted, + "snaps_deleted", "Snapshots deleted", "sdel", prio); + plb.add_u64(l_cephfs_mirror_directory_snaps_renamed, + "snaps_renamed", "Snapshots renamed", "sren", prio); + plb.add_u64(l_cephfs_mirror_directory_last_snap_id, + "last_snap_id", "Last synced snapshot id", "lsid", prio); + plb.add_u64(l_cephfs_mirror_directory_last_crawl_duration_seconds, + "last_crawl_duration_seconds", + "Last synced snapshot crawl duration seconds", "lcdn", prio); + plb.add_u64(l_cephfs_mirror_directory_last_datasync_wait_duration_seconds, + "last_datasync_wait_duration_seconds", + "Last synced snapshot datasync queue wait duration seconds", "ldwd", prio); + plb.add_u64(l_cephfs_mirror_directory_last_sync_duration_seconds, + "last_sync_duration_seconds", + "Last synced snapshot duration seconds", "lsdn", prio); + plb.add_time(l_cephfs_mirror_directory_last_sync_timestamp, + "last_sync_timestamp", "Last synced snapshot timestamp", "lsts", prio); + plb.add_u64(l_cephfs_mirror_directory_last_sync_bytes, + "last_sync_bytes", "Last synced snapshot bytes", "lsby", prio); + plb.add_u64(l_cephfs_mirror_directory_last_sync_files, + "last_sync_files", "Last synced snapshot files", "lsfl", prio); + + PerfCounters *perf = plb.create_perf_counters(); + m_cct->get_perfcounters_collection()->add(perf); + m_directory_perf_counters.emplace(dir_root, perf); +} + +void PeerReplayer::remove_directory_perf_counters(const std::string &dir_root) { + auto it = m_directory_perf_counters.find(dir_root); + if (it == m_directory_perf_counters.end()) { + return; + } + m_cct->get_perfcounters_collection()->remove(it->second); + delete it->second; + m_directory_perf_counters.erase(it); +} + +PerfCounters *PeerReplayer::find_directory_perf_counters(const std::string &dir_root) { + auto it = m_directory_perf_counters.find(dir_root); + if (it == m_directory_perf_counters.end()) { + return nullptr; + } + return it->second; +} + +void PeerReplayer::update_directory_current_sync_perf_counters( + PerfCounters *perf, const SnapSyncStat &sync_stat) { + if (!perf) { + return; + } + + auto clear_current = [&perf]() { + perf->set(l_cephfs_mirror_directory_current_snap_id, 0); + perf->set(l_cephfs_mirror_directory_current_sync_mode, 0); + perf->set(l_cephfs_mirror_directory_current_read_bps, 0); + perf->set(l_cephfs_mirror_directory_current_write_bps, 0); + perf->set(l_cephfs_mirror_directory_crawl_state, 0); + perf->set(l_cephfs_mirror_directory_crawl_duration_seconds, 0); + perf->set(l_cephfs_mirror_directory_datasync_wait_state, 0); + perf->set(l_cephfs_mirror_directory_datasync_wait_duration_seconds, 0); + perf->set(l_cephfs_mirror_directory_current_sync_bytes, 0); + perf->set(l_cephfs_mirror_directory_current_total_bytes, 0); + perf->set(l_cephfs_mirror_directory_current_sync_bytes_percent, 0); + perf->set(l_cephfs_mirror_directory_current_sync_files, 0); + perf->set(l_cephfs_mirror_directory_current_total_files, 0); + perf->set(l_cephfs_mirror_directory_current_sync_files_percent, 0); + perf->set(l_cephfs_mirror_directory_current_eta_valid, 0); + perf->set(l_cephfs_mirror_directory_current_eta_seconds, 0); + }; + + if (sync_stat.failed) { + perf->set(l_cephfs_mirror_directory_dir_state, 2); + clear_current(); + return; + } + + if (!sync_stat.current_syncing_snap) { + perf->set(l_cephfs_mirror_directory_dir_state, 0); + clear_current(); + return; + } + + perf->set(l_cephfs_mirror_directory_dir_state, 1); + perf->set(l_cephfs_mirror_directory_current_snap_id, + sync_stat.current_syncing_snap->first); + perf->set(l_cephfs_mirror_directory_current_sync_mode, + sync_stat.snapdiff ? 1 : 0); + + double read_bps = sync_stat.read_time_sec > 0 ? + sync_stat.bytes_read / sync_stat.read_time_sec : 0; + double write_bps = sync_stat.write_time_sec > 0 ? + sync_stat.bytes_written / sync_stat.write_time_sec : 0; + perf->set(l_cephfs_mirror_directory_current_read_bps, + static_cast(read_bps)); + perf->set(l_cephfs_mirror_directory_current_write_bps, + static_cast(write_bps)); + + if (sync_stat.crawl_finished) { + perf->set(l_cephfs_mirror_directory_crawl_state, 2); + perf->set(l_cephfs_mirror_directory_crawl_duration_seconds, + static_cast(sync_stat.crawl_duration)); + } else { + perf->set(l_cephfs_mirror_directory_crawl_state, 1); + auto cur_time = clock::now(); + sec_duration crawl_duration = + sec_duration(cur_time - sync_stat.crawl_start_time); + perf->set(l_cephfs_mirror_directory_crawl_duration_seconds, + static_cast(crawl_duration.count())); + } + + if (sync_stat.datasync_queue_wait_duration) { + perf->set(l_cephfs_mirror_directory_datasync_wait_state, 2); + perf->set(l_cephfs_mirror_directory_datasync_wait_duration_seconds, + static_cast(*sync_stat.datasync_queue_wait_duration)); + } else if (sync_stat.datasync_queue_wait_start_time) { + perf->set(l_cephfs_mirror_directory_datasync_wait_state, 1); + auto cur_time = clock::now(); + sec_duration dq_wait = + sec_duration(cur_time - *sync_stat.datasync_queue_wait_start_time); + perf->set(l_cephfs_mirror_directory_datasync_wait_duration_seconds, + static_cast(dq_wait.count())); + } else { + perf->set(l_cephfs_mirror_directory_datasync_wait_state, 0); + perf->set(l_cephfs_mirror_directory_datasync_wait_duration_seconds, 0); + } + + perf->set(l_cephfs_mirror_directory_current_sync_bytes, sync_stat.sync_bytes); + perf->set(l_cephfs_mirror_directory_current_total_bytes, sync_stat.total_bytes); + perf->set(l_cephfs_mirror_directory_current_sync_bytes_percent, + percent_basis_points(sync_stat.sync_bytes, sync_stat.total_bytes)); + perf->set(l_cephfs_mirror_directory_current_sync_files, sync_stat.sync_files); + perf->set(l_cephfs_mirror_directory_current_total_files, sync_stat.total_files); + perf->set(l_cephfs_mirror_directory_current_sync_files_percent, + percent_basis_points(sync_stat.sync_files, sync_stat.total_files)); + + SnapSyncStat stat_for_eta = sync_stat; + double eta = compute_eta(stat_for_eta); + if (eta == -1.0) { + perf->set(l_cephfs_mirror_directory_current_eta_valid, 0); + perf->set(l_cephfs_mirror_directory_current_eta_seconds, 0); + } else { + perf->set(l_cephfs_mirror_directory_current_eta_valid, 1); + perf->set(l_cephfs_mirror_directory_current_eta_seconds, + static_cast(eta)); + } +} + +void PeerReplayer::refresh_directory_current_sync_perf_counters( + const std::string &dir_root) { + // caller must hold m_lock + auto it = m_snap_sync_stats.find(dir_root); + if (it == m_snap_sync_stats.end()) { + return; + } + PerfCounters *dir_perf = find_directory_perf_counters(dir_root); + update_directory_current_sync_perf_counters(dir_perf, it->second); +} + int PeerReplayer::init() { dout(20) << ": initial dir list=[" << m_directories << "]" << dendl; for (auto &dir_root : m_directories) { m_snap_sync_stats.emplace(dir_root, SnapSyncStat()); } + for (auto &dir_root : m_directories) { + create_directory_perf_counters(dir_root); + } auto &remote_client = m_peer.remote.client_name; auto &remote_cluster = m_peer.remote.cluster_name; @@ -378,6 +638,10 @@ void PeerReplayer::add_directory(string_view dir_root) { } m_directories.emplace_back(_dir_root); m_snap_sync_stats.emplace(_dir_root, SnapSyncStat()); + if (m_directory_perf_counters.find(_dir_root) == + m_directory_perf_counters.end()) { + create_directory_perf_counters(_dir_root); + } m_cond.notify_all(); } @@ -393,6 +657,7 @@ void PeerReplayer::remove_directory(string_view dir_root) { auto it1 = m_registered.find(_dir_root); if (it1 == m_registered.end()) { + remove_directory_perf_counters(_dir_root); m_snap_sync_stats.erase(_dir_root); } else { it1->second.canceled = true; @@ -460,6 +725,7 @@ void PeerReplayer::unregister_directory(const std::string &dir_root) { unlock_directory(it->first, it->second); m_registered.erase(it); if (std::find(m_directories.begin(), m_directories.end(), dir_root) == m_directories.end()) { + remove_directory_perf_counters(dir_root); m_snap_sync_stats.erase(dir_root); } } @@ -2359,6 +2625,11 @@ void PeerReplayer::run_tick() { dout(5) << ": shutting down exiting" << dendl; break; } + + // refresh current-sync perf counters for registered directories + for (const auto &kv : m_registered) { + refresh_directory_current_sync_perf_counters(kv.first); + } } } diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index e91c5b495b2..38e54516bdd 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -665,6 +665,14 @@ private: ServiceDaemonStats m_service_daemon_stats; PerfCounters *m_perf_counters; + std::map m_directory_perf_counters; + + void create_directory_perf_counters(const std::string &dir_root); + void remove_directory_perf_counters(const std::string &dir_root); + PerfCounters *find_directory_perf_counters(const std::string &dir_root); + void update_directory_current_sync_perf_counters(PerfCounters *perf, + const SnapSyncStat &sync_stat); + void refresh_directory_current_sync_perf_counters(const std::string &dir_root); void run(SnapshotReplayerThread *replayer); void run_datasync(SnapshotDataSyncThread *data_replayer); From 13b0a36eec562c04d25fe13dd64f21233b11e993 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 22:27:12 +0530 Subject: [PATCH 258/596] cephfs_mirror: update per-directory last sync and summary perf counters Wire the remaining cephfs_mirror_directory labeled counters that were registered in the prior commit but not yet refreshed. Live current_syncing_snap gauges continue to be updated from the per-peer tick thread; this commit updates last_synced_snap and per-directory snap summary counters at the points where SnapSyncStat is actually modified. Depends-on the cephfs_mirror_directory PerfCounters schema (dir_state, current_*, last_*, snaps_*) added when each mirrored directory is registered. Prometheus / counter dump ------------------------- These counters appear under "cephfs_mirror_directory" in "counter dump" with the same labels as current-sync metrics (source_fscid, source_filesystem, peer_uuid, peer_cluster_name, peer_cluster_filesystem, directory). ceph-exporter exposes them as e.g. ceph_cephfs_mirror_directory_last_sync_bytes and ceph_cephfs_mirror_directory_snaps_synced. Unlike cephfs_mirror_peers, values are per (peer_uuid, directory) rather than aggregated across all directories on the peer. Peer-level counters are unchanged. Fixes: https://tracker.ceph.com/issues/73457 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 70 +++++++++++++++++++++++++ src/tools/cephfs_mirror/PeerReplayer.h | 18 +++++++ 2 files changed, 88 insertions(+) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index d532b6a3adf..6b370df3c5c 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -273,6 +273,10 @@ uint64_t percent_basis_points(uint64_t num, uint64_t den) { return static_cast((static_cast(num) * 10000.0) / den); } +double monotime_to_double(monotime t) { + return sec_duration(t.time_since_epoch()).count(); +} + void PeerReplayer::create_directory_perf_counters(const std::string &dir_root) { ceph_assert(m_directory_perf_counters.find(dir_root) == m_directory_perf_counters.end()); @@ -475,6 +479,54 @@ void PeerReplayer::update_directory_current_sync_perf_counters( } } +void PeerReplayer::update_directory_last_sync_perf_counters( + PerfCounters *perf, const SnapSyncStat &sync_stat) { + if (!perf) { + return; + } + + if (sync_stat.last_synced_snap) { + perf->set(l_cephfs_mirror_directory_last_snap_id, + sync_stat.last_synced_snap->first); + } else { + perf->set(l_cephfs_mirror_directory_last_snap_id, 0); + } + + perf->set(l_cephfs_mirror_directory_last_crawl_duration_seconds, + sync_stat.last_sync_crawl_duration ? + static_cast(*sync_stat.last_sync_crawl_duration) : 0); + perf->set(l_cephfs_mirror_directory_last_datasync_wait_duration_seconds, + sync_stat.last_sync_datasync_queue_wait_duration ? + static_cast(*sync_stat.last_sync_datasync_queue_wait_duration) : 0); + perf->set(l_cephfs_mirror_directory_last_sync_duration_seconds, + sync_stat.last_sync_duration ? + static_cast(*sync_stat.last_sync_duration) : 0); + + utime_t t; + if (!clock::is_zero(sync_stat.last_synced)) { + t.set_from_double(monotime_to_double(sync_stat.last_synced)); + } else { + t = utime_t(); + } + perf->tset(l_cephfs_mirror_directory_last_sync_timestamp, t); + + perf->set(l_cephfs_mirror_directory_last_sync_bytes, + sync_stat.last_sync_bytes ? *sync_stat.last_sync_bytes : 0); + perf->set(l_cephfs_mirror_directory_last_sync_files, + sync_stat.last_sync_files ? *sync_stat.last_sync_files : 0); +} + +void PeerReplayer::update_directory_summary_perf_counters( + PerfCounters *perf, const SnapSyncStat &sync_stat) { + if (!perf) { + return; + } + + perf->set(l_cephfs_mirror_directory_snaps_synced, sync_stat.synced_snap_count); + perf->set(l_cephfs_mirror_directory_snaps_deleted, sync_stat.deleted_snap_count); + perf->set(l_cephfs_mirror_directory_snaps_renamed, sync_stat.renamed_snap_count); +} + void PeerReplayer::refresh_directory_current_sync_perf_counters( const std::string &dir_root) { // caller must hold m_lock @@ -493,6 +545,11 @@ int PeerReplayer::init() { } for (auto &dir_root : m_directories) { create_directory_perf_counters(dir_root); + auto *dir_perf = find_directory_perf_counters(dir_root); + update_directory_last_sync_perf_counters(dir_perf, + m_snap_sync_stats.at(dir_root)); + update_directory_summary_perf_counters(dir_perf, + m_snap_sync_stats.at(dir_root)); } auto &remote_client = m_peer.remote.client_name; @@ -642,6 +699,11 @@ void PeerReplayer::add_directory(string_view dir_root) { m_directory_perf_counters.end()) { create_directory_perf_counters(_dir_root); } + auto *dir_perf = find_directory_perf_counters(_dir_root); + update_directory_last_sync_perf_counters(dir_perf, + m_snap_sync_stats.at(_dir_root)); + update_directory_summary_perf_counters(dir_perf, + m_snap_sync_stats.at(_dir_root)); m_cond.notify_all(); } @@ -2610,6 +2672,10 @@ int PeerReplayer::sync_snaps(const std::string &dir_root, } else { _reset_failed_count(dir_root); } + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_current_sync_perf_counters(dir_perf, + m_snap_sync_stats.at(dir_root)); + } return r; } @@ -2675,6 +2741,10 @@ void PeerReplayer::run(SnapshotReplayerThread *replayer) { } } else { _inc_failed_count(*dir_root); + if (auto *dir_perf = find_directory_perf_counters(*dir_root)) { + update_directory_current_sync_perf_counters( + dir_perf, m_snap_sync_stats.at(*dir_root)); + } if (m_perf_counters) { m_perf_counters->inc(l_cephfs_mirror_peer_replayer_snap_sync_failures); } diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 38e54516bdd..8d8d9f42119 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -488,6 +488,10 @@ private: const std::string &snap_name) { std::scoped_lock locker(m_lock); _set_last_synced_snap(dir_root, snap_id, snap_name); + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_last_sync_perf_counters(dir_perf, + m_snap_sync_stats.at(dir_root)); + } } void set_current_syncing_snap(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name) { @@ -505,11 +509,17 @@ private: std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); ++sync_stat.deleted_snap_count; + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_summary_perf_counters(dir_perf, sync_stat); + } } void inc_renamed_snap(const std::string &dir_root) { std::scoped_lock locker(m_lock); auto &sync_stat = m_snap_sync_stats.at(dir_root); ++sync_stat.renamed_snap_count; + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_summary_perf_counters(dir_perf, sync_stat); + } } void set_last_synced_stat(const std::string &dir_root, uint64_t snap_id, const std::string &snap_name, double duration) { @@ -526,6 +536,10 @@ private: sync_stat.last_sync_files = sync_stat.sync_files; ++sync_stat.synced_snap_count; _reset_sync_stat(dir_root); + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_last_sync_perf_counters(dir_perf, sync_stat); + update_directory_summary_perf_counters(dir_perf, sync_stat); + } } void set_snapdiff(const std::string &dir_root, bool snapdiff) { std::scoped_lock locker(m_lock); @@ -672,6 +686,10 @@ private: PerfCounters *find_directory_perf_counters(const std::string &dir_root); void update_directory_current_sync_perf_counters(PerfCounters *perf, const SnapSyncStat &sync_stat); + void update_directory_last_sync_perf_counters(PerfCounters *perf, + const SnapSyncStat &sync_stat); + void update_directory_summary_perf_counters(PerfCounters *perf, + const SnapSyncStat &sync_stat); void refresh_directory_current_sync_perf_counters(const std::string &dir_root); void run(SnapshotReplayerThread *replayer); From c5be81c79d383acc8b1295a938e2854b0fe51d94 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 22:58:33 +0530 Subject: [PATCH 259/596] doc/cephfs: Document cephfs_mirror_directory counter dump metrics Describe per-directory labeled perf counters, labels, update behavior, mapping to peer status, and counter reference tables. Document the per-peer tick thread and cephfs_mirror_tick_interval, which refreshes current-sync gauges on each tick. Add a PendingReleaseNotes entry for the new cephfs_mirror_directory perf counter group. Fixes: https://tracker.ceph.com/issues/73457 Signed-off-by: Kotresh HR --- PendingReleaseNotes | 6 + doc/cephfs/cephfs-mirroring.rst | 323 +++++++++++++++++++++++++++++++- 2 files changed, 325 insertions(+), 4 deletions(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 2e2a19fc25f..d54ec981f3f 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -174,6 +174,12 @@ data-sync queue timing, bytes/files progress, and ETA). Output is grouped under ``metrics//peer/``. For more information, see https://tracker.ceph.com/issues/73453 +* CephFS Mirroring: Per-directory snapshot sync progress is exposed as labeled perf + counters in the ``cephfs_mirror_directory`` group (``counter dump`` on the mirror + daemon admin socket, exportable via ``ceph-exporter``). Counters mirror + ``fs mirror peer status`` fields (directory state, current sync, last sync, and + snap summary) and are labeled by peer UUID and mirrored directory path. See + https://tracker.ceph.com/issues/73457 * RBD: Mirror snapshot creation and trash purge schedules are now automatically staggered when no explicit "start-time" is specified. This reduces scheduling spikes and distributes work more evenly over time. diff --git a/doc/cephfs/cephfs-mirroring.rst b/doc/cephfs/cephfs-mirroring.rst index 5b12ed4dffc..b10184112b4 100644 --- a/doc/cephfs/cephfs-mirroring.rst +++ b/doc/cephfs/cephfs-mirroring.rst @@ -711,9 +711,39 @@ will not show up in command help. Metrics ------- -CephFS exports mirroring metrics as :ref:`Labeled Perf Counters` which will be consumed by the OCP/ODF Dashboard to provide monitoring of the Geo Replication. These metrics can be used to measure the progress of cephfs-mirror syncing and thus provide the monitoring capability. CephFS exports the following mirroring metrics, which are displayed using the ``counter dump`` command. +CephFS exports mirroring metrics as :ref:`Labeled Perf Counters` for scraping by +monitoring tools (for example Prometheus via ``ceph-exporter``). Operators can inspect +them with the mirror daemon admin socket ``counter dump`` command (see +:ref:`cephfs_mirror_counter_dump`). -.. list-table:: Mirror Status Metrics +Three labeled counter groups are relevant for snapshot mirroring: + +- ``cephfs_mirror_mirrored_filesystems`` — per primary file system on a mirror daemon + (for example ``directory_count``, ``mirroring_peers``). +- ``cephfs_mirror_peers`` — aggregated across all mirrored directories for one peer + on a file system. +- ``cephfs_mirror_directory`` — per mirrored directory path and peer (new; see + :ref:`cephfs_mirror_directory_perf_counters`). + +The JSON returned by ``fs mirror peer status`` and ``ceph fs snapshot mirror status`` +describes the same synchronization state in human-readable form. The +``cephfs_mirror_directory`` counters expose that state as numeric gauges for monitoring +systems. + +.. _cephfs_mirror_counter_dump: + +Accessing perf counters +~~~~~~~~~~~~~~~~~~~~~~~ + +On a host running ``cephfs-mirror``:: + + $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok counter dump + +Labeled groups appear as top-level JSON arrays (for example ``"cephfs_mirror_directory": [ ... ]``). +Each array element has a ``labels`` object and a ``counters`` object. Use +``counter schema`` on the same admin socket to list counter names and types. + +.. list-table:: Mirror filesystem metrics (``cephfs_mirror_mirrored_filesystems``) :widths: 25 25 75 :header-rows: 1 @@ -733,7 +763,7 @@ CephFS exports mirroring metrics as :ref:`Labeled Perf Counters` which will be c - Counter - Enable mirroring failures -.. list-table:: Replication Metrics +.. list-table:: Peer replication metrics (``cephfs_mirror_peers``) :widths: 25 25 75 :header-rows: 1 @@ -742,7 +772,7 @@ CephFS exports mirroring metrics as :ref:`Labeled Perf Counters` which will be c - Description * - snaps_synced - Counter - - The total number of snapshots successfully synchronized + - Total snapshots synchronized for this peer on this file system (all directories combined) * - sync_bytes - Counter - The total bytes being synchronized @@ -771,6 +801,290 @@ CephFS exports mirroring metrics as :ref:`Labeled Perf Counters` which will be c - Counter - The total bytes being synchronized for the last synced snapshot +Peer-level counters are labeled with ``source_fscid``, ``source_filesystem``, +``peer_cluster_name``, and ``peer_cluster_filesystem``. They do not include +``peer_uuid`` or ``directory``; use ``cephfs_mirror_directory`` for per-path detail. + +.. _cephfs_mirror_directory_perf_counters: + +Per-directory replication metrics (``cephfs_mirror_directory``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``cephfs_mirror_directory`` labeled counter group reports snapshot mirror +progress for a single mirrored directory path toward a single peer. One perf counter +instance is created when a directory is added for mirroring (``fs snapshot mirror add``) +and removed when the directory is removed from the policy. + +This matches the per-directory fields under ``metrics//peer/`` +in ``fs mirror peer status`` (see :ref:`cephfs_mirroring_mgr_snapshot_status`), but uses +raw numeric values suitable for graphs and alerts (bytes per second, seconds, basis points). + +**Labels** + +Each ``cephfs_mirror_directory`` entry in ``counter dump`` includes: + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Label + - Description + * - ``source_fscid`` + - File system cluster ID on the primary cluster + * - ``source_filesystem`` + - File system name on the primary cluster + * - ``peer_uuid`` + - Mirror peer UUID (disambiguates the same directory path mirrored to multiple peers) + * - ``peer_cluster_name`` + - Remote cluster name of the peer + * - ``peer_cluster_filesystem`` + - Remote file system name on the peer + * - ``directory`` + - Mirrored directory path (for example ``/parent/d1``) + +Example (one directory, one peer):: + + $ ceph --admin-daemon /var/run/ceph/cephfs-mirror.asok counter dump + { + "cephfs_mirror_directory": [ + { + "labels": { + "source_fscid": "360", + "source_filesystem": "cephfs", + "peer_uuid": "8a85ab25-70f9-48e9-b82d-56324e75209b", + "peer_cluster_name": "site-a", + "peer_cluster_filesystem": "backup_fs", + "directory": "/parent/d1" + }, + "counters": { + "dir_state": 0, + "current_snap_id": 0, + "snaps_synced": 1, + "last_snap_id": 3, + "last_sync_bytes": 157286400, + "last_sync_files": 5000, + ... + } + } + ] + } + +When exported through ``ceph-exporter``, counter names are prefixed (for example +``ceph_cephfs_mirror_directory_current_sync_bytes``) with label dimensions attached. + +**Update frequency** + +Counters are not updated on every file read or write. Behavior differs by field group: + +- **Current sync gauges** (``current_*``, ``crawl_*``, ``datasync_wait_*``, ``dir_state`` + while syncing): refreshed by the :ref:`per-peer tick thread ` + for each registered directory, on the interval configured by + ``cephfs_mirror_tick_interval`` (default ``5`` seconds). Only directories that are + actively registered for synchronization on this daemon are updated. +- **Last synced gauges** (``last_*``): updated when a snapshot sync completes and when + a directory is added for mirroring. +- **Summary gauges** (``snaps_synced``, ``snaps_deleted``, ``snaps_renamed``): updated when + the corresponding per-directory counters change (sync complete, snap delete, snap rename). + +**Mapping to ``fs mirror peer status``** + +.. list-table:: + :widths: 35 35 30 + :header-rows: 1 + + * - Perf counter + - Admin socket / mgr JSON field + - Notes + * - ``dir_state`` + - ``state`` + - ``0`` = idle, ``1`` = syncing, ``2`` = failed (not ``stale``; stale is mgr-only) + * - ``current_snap_id`` + - ``current_syncing_snap.id`` + - ``0`` when idle or failed + * - ``current_sync_mode`` + - ``current_syncing_snap.sync-mode`` + - ``0`` = full, ``1`` = delta + * - ``current_read_bps`` / ``current_write_bps`` + - ``avg_read_throughput_bytes`` / ``avg_write_throughput_bytes`` + - Raw bytes per second (not human-readable strings) + * - ``crawl_state`` / ``crawl_duration_seconds`` + - ``current_syncing_snap.crawl`` + - See crawl state table below + * - ``datasync_wait_state`` / ``datasync_wait_duration_seconds`` + - ``current_syncing_snap.datasync_queue_wait`` + - See datasync wait state table below + * - ``current_sync_bytes`` / ``current_total_bytes`` / ``current_sync_bytes_percent`` + - ``current_syncing_snap.bytes`` + - Percent is basis points (``4029`` = 40.29%) + * - ``current_sync_files`` / ``current_total_files`` / ``current_sync_files_percent`` + - ``current_syncing_snap.files`` + - Percent is basis points + * - ``current_eta_valid`` / ``current_eta_seconds`` + - ``current_syncing_snap.eta`` + - ``current_eta_valid``: ``0`` = calculating, ``1`` = ETA available + * - ``last_snap_id`` and ``last_*_duration_seconds``, ``last_sync_bytes``, ``last_sync_files`` + - ``last_synced_snap`` + - Durations in seconds; bytes are raw counts + * - ``last_sync_timestamp`` + - ``last_synced_snap.sync_time_stamp`` + - ``utime_t`` (seconds since epoch), not the monotonic string shown in admin JSON + * - ``snaps_synced`` / ``snaps_deleted`` / ``snaps_renamed`` + - Same field names at directory level + - Reset on daemon restart or directory reassignment (same as admin socket) + +**Directory state (``dir_state``)** + +.. list-table:: + :widths: 15 85 + :header-rows: 1 + + * - Value + - Meaning + * - ``0`` + - Idle — no snapshot is currently being synchronized for this directory + * - ``1`` + - Syncing — ``current_*`` gauges reflect in-progress snapshot sync + * - ``2`` + - Failed — directory hit the consecutive failure limit; ``current_*`` gauges are cleared + +When ``dir_state`` is ``0`` or ``2``, all ``current_*``, ``crawl_*``, and ``datasync_wait_*`` +counters are set to zero. + +**Current syncing snapshot counters** + +Present while ``dir_state`` is ``1``. Corresponds to ``current_syncing_snap`` in +``fs mirror peer status``. + +.. list-table:: + :widths: 30 15 55 + :header-rows: 1 + + * - Counter + - Type + - Description + * - ``current_snap_id`` + - Gauge + - Snapshot ID being synchronized + * - ``current_sync_mode`` + - Gauge + - ``0`` = full sync, ``1`` = delta (snapdiff/blockdiff) + * - ``current_read_bps`` + - Gauge + - Average primary read throughput in bytes per second for this sync + * - ``current_write_bps`` + - Gauge + - Average remote write throughput in bytes per second for this sync + * - ``crawl_state`` + - Gauge + - ``0`` = not applicable, ``1`` = in progress, ``2`` = completed + * - ``crawl_duration_seconds`` + - Gauge + - Crawl duration in seconds (elapsed while in progress, total when completed) + * - ``datasync_wait_state`` + - Gauge + - ``0`` = none, ``1`` = waiting in data-sync queue, ``2`` = transfer started + * - ``datasync_wait_duration_seconds`` + - Gauge + - Time in the data-sync queue in seconds + * - ``current_sync_bytes`` + - Gauge + - Bytes synchronized so far for this snapshot + * - ``current_total_bytes`` + - Gauge + - Total bytes discovered for this snapshot sync + * - ``current_sync_bytes_percent`` + - Gauge + - Sync progress in **basis points** (``10000`` = 100.00%) + * - ``current_sync_files`` + - Gauge + - Files synchronized so far + * - ``current_total_files`` + - Gauge + - Total files discovered for this snapshot sync + * - ``current_sync_files_percent`` + - Gauge + - File progress in **basis points** + * - ``current_eta_valid`` + - Gauge + - ``0`` = ETA not yet available (``calculating...`` in admin socket), ``1`` = ETA available + * - ``current_eta_seconds`` + - Gauge + - Estimated seconds remaining when ``current_eta_valid`` is ``1`` + +**Last synced snapshot counters** + +Updated after each successful snapshot synchronization. Corresponds to +``last_synced_snap`` in ``fs mirror peer status``. + +.. list-table:: + :widths: 35 15 50 + :header-rows: 1 + + * - Counter + - Type + - Description + * - ``last_snap_id`` + - Gauge + - Snapshot ID of the last successfully synchronized snapshot + * - ``last_crawl_duration_seconds`` + - Gauge + - Directory crawl duration for that sync + * - ``last_datasync_wait_duration_seconds`` + - Gauge + - Data-sync queue wait duration for that sync + * - ``last_sync_duration_seconds`` + - Gauge + - Total time to synchronize that snapshot + * - ``last_sync_timestamp`` + - Time + - Wall-clock time when that sync finished (``utime_t``) + * - ``last_sync_bytes`` + - Gauge + - Bytes synchronized for that snapshot + * - ``last_sync_files`` + - Gauge + - Files synchronized for that snapshot + +**Per-directory snapshot summary counters** + +.. list-table:: + :widths: 25 15 60 + :header-rows: 1 + + * - Counter + - Type + - Description + * - ``snaps_synced`` + - Gauge + - Snapshots successfully synchronized since the last counter reset + * - ``snaps_deleted`` + - Gauge + - Snapshot deletes propagated to the peer since the last reset + * - ``snaps_renamed`` + - Gauge + - Snapshot renames propagated to the peer since the last reset + +These counters reset when the mirror daemon restarts or when a directory is reassigned to +another mirror daemon, consistent with ``fs mirror peer status``. + +.. _cephfs_mirror_tick_thread: + +Per-peer tick thread +~~~~~~~~~~~~~~~~~~~~ + +Each mirror peer handled by a ``cephfs-mirror`` daemon runs a dedicated tick thread in its +``PeerReplayer``. The thread wakes every :confval:`cephfs_mirror_tick_interval` seconds +(default ``5``), re-reads that option on each iteration so configuration changes take effect +without restarting the daemon, and runs periodic mirroring work. + +Currently, the tick thread refreshes the ``current_*``, ``crawl_*``, ``datasync_wait_*``, and +``dir_state`` fields of :ref:`cephfs_mirror_directory_perf_counters` for each directory that +is actively registered for synchronization on the daemon. + +Example — set the tick interval to 10 seconds for the mirror daemon user:: + + ceph config set client.mirror cephfs_mirror_tick_interval 10 + Configuration Options --------------------- @@ -787,6 +1101,7 @@ Configuration Options .. confval:: cephfs_mirror_restart_mirror_on_failure_interval .. confval:: cephfs_mirror_mount_timeout .. confval:: cephfs_mirror_perf_stats_prio +.. confval:: cephfs_mirror_tick_interval .. confval:: cephfs_mirror_blockdiff_min_file_size Re-adding Peers From 23c910f52a9f49d95875f4040c5114bd680a61db Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Fri, 12 Jun 2026 17:11:15 +0530 Subject: [PATCH 260/596] doc/mgr/smb: document client compatibility mode Add documentation for --client-compat parameter in 'cluster create' command and new 'cluster update client-compat' command. This feature enables macOS-specific SMB optimizations (fruit VFS, streams_xattr) and can be set during cluster creation or updated for existing clusters. Signed-off-by: Shweta Sodani --- doc/mgr/smb.rst | 83 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/doc/mgr/smb.rst b/doc/mgr/smb.rst index ad70c13948a..6cf26482a58 100644 --- a/doc/mgr/smb.rst +++ b/doc/mgr/smb.rst @@ -55,7 +55,7 @@ Create Cluster .. prompt:: bash # - ceph smb cluster create {user|active-directory} [--domain-realm=] [--domain-join-user-pass=] [--define-user-pass=] [--custom-dns=] [--placement=] [--clustering=] [--password-filter=] [--password-filter-out=] + ceph smb cluster create {user|active-directory} [--domain-realm=] [--domain-join-user-pass=] [--define-user-pass=] [--custom-dns=] [--placement=] [--clustering=] [--client-compat={default|macos}] [--password-filter=] [--password-filter-out=] Create a new logical cluster, identified by the cluster ID value. The cluster create command must specify the authentication mode the cluster will use. This @@ -100,6 +100,11 @@ clustering enables clustering regardless of the placement count. A value of ``never`` disables clustering regardless of the placement count. If unspecified, ``default`` is assumed. +client_compat + Optional. One of ``default`` or ``macos``. Controls client-specific SMB + features and optimizations. The ``default`` mode provides standard SMB + behavior with broad compatibility. The ``macos`` mode enables macOS-specific + optimized settings for macOS clients. If unspecified, ``default`` is assumed. public_addrs Optional. A string in the form of [%]. Supported only when using Samba's clustering. Assign "virtual" IP addresses @@ -165,6 +170,15 @@ previous example. Set three CTDB public address values and a custom placement: --public-address=192.168.76.112/24 \ --placement="3 label:smb" +Create a cluster for macOS clients with user authentication: + +.. prompt:: bash # + + ceph smb cluster create mac_cluster user \ + --define-user-pass=macuser%Passw0rd1 \ + --client-compat=macos \ + --placement="label:smb" + Update Cluster QoS ++++++++++++++++++ @@ -216,6 +230,53 @@ Disable QoS for all shares in a cluster: --write-bw-limit=0 + +Update Cluster Client Compatibility +++++++++++++++++++++++++++++++++++++ + +.. prompt:: bash # + + ceph smb cluster update client-compat {default|macos} + +Update the client compatibility mode for an SMB cluster. This setting controls whether client-specific SMB features and optimizations are enabled. + +The client compatibility mode determines how the Samba server is configured to optimize for specific client types: + +- ``default``: Standard SMB behavior without client-specific optimizations. This is the default mode and provides broad compatibility with all SMB clients. +- ``macos``: Enable macOS-specific features including the Samba's fruit VFS module for proper handling of macOS metadata and optimized settings for macOS clients. + +When ``macos`` mode is enabled, the following features are automatically configured: + +- Fruit VFS module for proper handling of macOS-specific file attributes +- Streams_xattr VFS module for extended attribute support + +Options: + +client_compat + One of ``default`` or ``macos`` +cluster_id + A short string uniquely identifying the cluster + +Examples +~~~~~~~~ + +Enable macOS compatibility mode for a cluster: + +.. prompt:: bash # + + ceph smb cluster update client-compat macos mycluster + +Revert to default (standard) compatibility mode: + +.. prompt:: bash # + + ceph smb cluster update client-compat default mycluster + +.. note:: + The ``macos`` compatibility mode is recommended when the primary clients accessing the SMB shares are macOS systems. Otherwise default mode is recommended. + + + Remove Cluster ++++++++++++++ @@ -879,6 +940,12 @@ custom_smb_global_options indicator that the user is aware that using this option can easily break things in ways that the Ceph team can not help with. This special key will automatically be removed from the list of options passed to Samba. +client_compat + Optional. One of ``default`` or ``macos``. Controls client-specific SMB + features and optimizations. The ``default`` mode provides standard SMB + behavior with broad compatibility. The ``macos`` mode enables macOS-specific + features including Samba's fruit VFS module for proper handling of macOS and + optimized settings for macOS clients. If unspecified, ``default`` is assumed. .. warning:: Setting the ``clustering`` option allows an administrator to choose exactly @@ -977,6 +1044,20 @@ The following is an example of a cluster configured for standalone operation: hosts: - node6.mycluster.sink.test +The following is an example of a cluster optimized for macOS clients: + +.. code-block:: yaml + + resource_type: ceph.smb.cluster + cluster_id: macshare + auth_mode: user + client_compat: macos + user_group_settings: + - source_type: resource + ref: ug1 + placement: + count: 1 + An example cluster resource with intent to remove: .. code-block:: yaml From 3fc6457a8f77c9916476224c278fa8a6b6a8ceb1 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 23:01:13 +0530 Subject: [PATCH 261/596] qa: Add tests for cephfs_mirror_directory perf counters Verify counter dump registration, current-sync gauges while syncing (full/delta), last-sync and summary counters after idle sync, and extend mirror stats and remote-snap failure tests for per-directory snaps_* and dir_state. Fixes: https://tracker.ceph.com/issues/73457 Signed-off-by: Kotresh HR --- qa/tasks/cephfs/test_mirroring.py | 160 ++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/qa/tasks/cephfs/test_mirroring.py b/qa/tasks/cephfs/test_mirroring.py index f75f0216112..ad4642c4e7d 100644 --- a/qa/tasks/cephfs/test_mirroring.py +++ b/qa/tasks/cephfs/test_mirroring.py @@ -69,6 +69,7 @@ class TestMirroring(CephFSTestCase): PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR = "cephfs_mirror" PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_FS = "cephfs_mirror_mirrored_filesystems" PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_PEER = "cephfs_mirror_peers" + PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY = "cephfs_mirror_directory" def setUp(self): super(TestMirroring, self).setUp() @@ -295,6 +296,39 @@ class TestMirroring(CephFSTestCase): self.assertIn('metrics', res) return res['metrics'][dir_name]['peer'][peer_uuid] + def directory_perf_entry(self, res, dir_path, peer_uuid): + for entry in res.get(self.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY, []): + labels = entry.get('labels', {}) + if labels.get('directory') == dir_path and labels.get('peer_uuid') == peer_uuid: + return entry + return None + + def get_directory_perf_counters(self, dir_path, peer_uuid): + res = self.mirror_daemon_command( + f'counter dump for fs: {self.primary_fs_name}', 'counter', 'dump') + entry = self.directory_perf_entry(res, dir_path, peer_uuid) + self.assertIsNotNone(entry, + msg=f'missing {self.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY} ' + f'for {dir_path}') + return entry['counters'] + + def assert_directory_perf_labels(self, labels, dir_path, peer_uuid): + self.assertEqual(labels['source_fscid'], str(self.primary_fs_id)) + self.assertEqual(labels['source_filesystem'], self.primary_fs_name) + self.assertEqual(labels['peer_uuid'], peer_uuid) + self.assertEqual(labels['directory'], dir_path) + self.assertEqual(labels['peer_cluster_name'], 'ceph') + self.assertEqual(labels['peer_cluster_filesystem'], self.secondary_fs_name) + + def assert_idle_directory_last_sync_perf(self, counters): + self.assertEqual(counters['dir_state'], 0) + self.assertEqual(counters['current_snap_id'], 0) + self.assertGreater(counters['last_snap_id'], 0) + self.assertGreater(counters['last_sync_bytes'], 0) + self.assertGreater(counters['last_sync_files'], 0) + self.assertGreaterEqual(counters['last_sync_duration_seconds'], 0) + self.assertGreaterEqual(counters['snaps_synced'], 1) + def assert_last_synced_snap_metrics(self, last_synced_snap): for key in ('crawl_duration', 'datasync_queue_wait_duration', 'sync_duration', 'sync_time_stamp', 'sync_bytes', 'sync_files'): @@ -348,6 +382,30 @@ class TestMirroring(CephFSTestCase): e.res = res raise + @retry_assert(timeout=120, interval=1) + def check_directory_perf_syncing(self, fs_name, fs_id, dir_path, peer_spec, + sync_mode=None): + peer_uuid = self.get_peer_uuid(peer_spec) + res = self.mirror_daemon_command(f'counter dump for fs: {fs_name}', + 'counter', 'dump') + try: + entry = self.directory_perf_entry(res, dir_path, peer_uuid) + self.assertIsNotNone(entry) + counters = entry['counters'] + self.assertEqual(counters['dir_state'], 1) + self.assertGreater(counters['current_snap_id'], 0) + if sync_mode == 'full': + self.assertEqual(counters['current_sync_mode'], 0) + elif sync_mode == 'delta': + self.assertEqual(counters['current_sync_mode'], 1) + self.assertIn(counters['crawl_state'], (1, 2)) + self.assertGreater(counters['current_total_files'], 0) + if counters['current_total_bytes'] > 0: + self.assertLessEqual(counters['current_sync_bytes_percent'], 10000) + except RETRY_EXCEPTIONS as e: + e.res = res + raise + @retry_assert(timeout=60, interval=3) def check_peer_status_empty(self, fs_name, fs_id, peer_spec): peer_uuid = self.get_peer_uuid(peer_spec) @@ -820,6 +878,9 @@ class TestMirroring(CephFSTestCase): self.assertGreaterEqual(second["counters"]["last_synced_end"], second["counters"]["last_synced_start"]) self.assertGreater(second["counters"]["last_synced_duration"], 0) self.assertEqual(second["counters"]["last_synced_bytes"], 1048576000) # last_synced_bytes = 10 files of 1024MB size each + peer_uuid = self.get_peer_uuid("client.mirror_remote@ceph") + dir_second = self.get_directory_perf_counters('/d0', peer_uuid) + self.assertEqual(dir_second['snaps_synced'], second["counters"]["snaps_synced"]) # some more IO for i in range(15): @@ -853,6 +914,8 @@ class TestMirroring(CephFSTestCase): res = self.mirror_daemon_command(f'counter dump for fs: {self.primary_fs_name}', 'counter', 'dump') fourth = res[TestMirroring.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_PEER][0] self.assertGreater(fourth["counters"]["snaps_deleted"], third["counters"]["snaps_deleted"]) + dir_fourth = self.get_directory_perf_counters('/d0', peer_uuid) + self.assertGreater(dir_fourth['snaps_deleted'], dir_second['snaps_deleted']) # rename a snapshot self.mount_a.run_shell(["mv", "d0/.snap/snap1", "d0/.snap/snap2"]) @@ -866,6 +929,8 @@ class TestMirroring(CephFSTestCase): res = self.mirror_daemon_command(f'counter dump for fs: {self.primary_fs_name}', 'counter', 'dump') fifth = res[TestMirroring.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_PEER][0] self.assertGreater(fifth["counters"]["snaps_renamed"], fourth["counters"]["snaps_renamed"]) + dir_fifth = self.get_directory_perf_counters('/d0', peer_uuid) + self.assertGreater(dir_fifth['snaps_renamed'], dir_fourth['snaps_renamed']) self.remove_directory(self.primary_fs_name, self.primary_fs_id, '/d0') self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) @@ -1695,6 +1760,8 @@ class TestMirroring(CephFSTestCase): snap_name == dir_stat['last_synced_snap']['name'] and \ expected_snap_count == dir_stat['snaps_synced']): self.assert_last_synced_snap_metrics(dir_stat['last_synced_snap']) + self.assertEqual( + self.get_directory_perf_counters(dir_name, peer_uuid)['dir_state'], 2) break # remove the directory in the remote fs and check status restores to 'idle' self.mount_b.run_shell(['sudo', 'rmdir', remote_snap_path], omit_sudo=False) @@ -1768,6 +1835,99 @@ class TestMirroring(CephFSTestCase): peer_spec, f'/{dir_name}', snap1, 2) self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + def test_cephfs_mirror_directory_perf_counters_registration(self): + """counter dump exposes cephfs_mirror_directory per mirrored path.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, self.secondary_fs_name) + self.mount_a.run_shell(['mkdir', 'd0', 'd1']) + self.add_directory(self.primary_fs_name, self.primary_fs_id, '/d0') + self.add_directory(self.primary_fs_name, self.primary_fs_id, '/d1', check_perf_counter=False) + + peer_uuid = self.get_peer_uuid(peer_spec) + res = self.mirror_daemon_command(f'counter dump for fs: {self.primary_fs_name}', + 'counter', 'dump') + self.assertIn(self.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY, res) + dirs = {e['labels']['directory'] + for e in res[self.PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY] + if e['labels'].get('peer_uuid') == peer_uuid} + self.assertEqual(dirs, {'/d0', '/d1'}) + self.assert_directory_perf_labels( + self.directory_perf_entry(res, '/d0', peer_uuid)['labels'], '/d0', peer_uuid) + + self.remove_directory(self.primary_fs_name, self.primary_fs_id, '/d0') + with safe_while(sleep=2, tries=30, + action='wait for directory perf counter removal') as proceed: + while proceed(): + res = self.mirror_daemon_command( + f'counter dump for fs: {self.primary_fs_name}', 'counter', 'dump') + if self.directory_perf_entry(res, '/d0', peer_uuid) is None: + break + res = self.mirror_daemon_command(f'counter dump for fs: {self.primary_fs_name}', + 'counter', 'dump') + self.assertIsNotNone(self.directory_perf_entry(res, '/d1', peer_uuid)) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_cephfs_mirror_directory_perf_counters_syncing(self): + """counter dump current_* gauges update while a snapshot is syncing.""" + self.setup_mount_b(mds_perm='rw') + self.config_set('client.mirror', 'cephfs_mirror_tick_interval', 1) + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, self.secondary_fs_name) + + dir_name = 'd0' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 3000, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap0 = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap0}']) + self.check_directory_perf_syncing( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_spec, + sync_mode='full') + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap0, 1) + + self.mount_a.write_n_mb(os.path.join(dir_name, 'file.0'), 1) + self.mount_a.create_n_files(f'{dir_name}/snapdiff_file', 3000, sync=True) + snap1 = 'snap1' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap1}']) + self.check_directory_perf_syncing( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_spec, + sync_mode='delta') + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap1, 2) + + peer_uuid = self.get_peer_uuid(peer_spec) + idle = self.get_directory_perf_counters(f'/{dir_name}', peer_uuid) + self.assertEqual(idle['dir_state'], 0) + self.assertEqual(idle['current_snap_id'], 0) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_cephfs_mirror_directory_perf_counters_last_sync_and_summary(self): + """counter dump last_* and snaps_* gauges reflect completed directory sync.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, self.secondary_fs_name) + + dir_name = 'metrics_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap_name, 1) + + peer_uuid = self.get_peer_uuid(peer_spec) + self.assert_idle_directory_last_sync_perf( + self.get_directory_perf_counters(f'/{dir_name}', peer_uuid)) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + def test_cephfs_mirror_sync_already_existing_snapshots(self): """ That mirroring syncs the already existing snapshot correctly. From f78b44aba3954155709fb32b0f67d2bea73edfb2 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Mon, 15 Jun 2026 19:38:20 -0400 Subject: [PATCH 262/596] doc/releases/tentacle: remove bogus notable changes Resolves: https://tracker.ceph.com/issues/77357 Signed-off-by: Patrick Donnelly --- doc/releases/tentacle.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/doc/releases/tentacle.rst b/doc/releases/tentacle.rst index 0534c1438ef..bce02033a02 100644 --- a/doc/releases/tentacle.rst +++ b/doc/releases/tentacle.rst @@ -22,26 +22,18 @@ Notable Changes MDS (Metadata Server) --------------------- -* Upgrade Testing Architecture: Split up existing upgrade sequences into distinct CentOS Stream 9 and Rocky Linux 10 automated test variants across multiple upgrade suites (including parallel, stress-split, and telemetry suites). -* Queueing Logic Update: Retry requests are now added directly to the MDSRank wait queue rather than going through the finisher. -* Optimization: Removed duplicate context completion calls and adjusted scan_stray_dir after refining the MDSContext class. -* Reversion: Reverted a prior change that moved MDSContext completion handling to the finish method. * Segmentation fault fixed due to incorrect queueing of request retries. OSD (Object Storage Daemon) --------------------------- -* FastEC Activation: Always updates the pwlc epoch when activating. * PGLog Missed List: Fixed a bug to ensure the correct version is attached to the missing list when ignoring log entries. * Data Integrity Asserts: Added assertions to explicitly catch potential corruption in the OSD missing list. -* Data Structure Changes: Modified the rmissing map key type from version_t to eversion_t. -* Rollback & Vector Fixes: Corrected rollback logic for partial write object information (OI) and optimized Erasure Coding (EC) by ensuring Twiddle creates a full-sized vector. RGW (RADOS Gateway) ------------------- * Lifecycle Management: Fixed lifecycle transition issues affecting encrypted multipart objects. -* Multisite Concurrency: Improved concurrency handling when a value of 1 is provided by the caller, added logging for concurrency state transitions within adj_concurrency, exposed lock latency as a performance counter for data synchronization, and fixed an uninitialized LatencyMonitor average by shifting to an exponentially weighted moving average. * REST & Query Handling: RESTArgs::get_string() now properly URL-decodes incoming query parameters. RADOS / librados / neorados From 1578756c74f88a094f01ff08cc514e2908c9f431 Mon Sep 17 00:00:00 2001 From: Patrick Donnelly Date: Mon, 15 Jun 2026 22:51:58 -0400 Subject: [PATCH 263/596] doc/releases/tentacle: scope wording to package installs Resolves: https://tracker.ceph.com/issues/77357 Signed-off-by: Patrick Donnelly --- doc/releases/tentacle.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/releases/tentacle.rst b/doc/releases/tentacle.rst index bce02033a02..07a6d66b24f 100644 --- a/doc/releases/tentacle.rst +++ b/doc/releases/tentacle.rst @@ -17,7 +17,7 @@ June 16, 2026 Notable Changes --------------- -* Rocky 10 is now supported starting with v20.2.2. Please see the `supported platforms `_ for current and planned support in Ceph. +* Rocky 10 package-based installs are now supported starting with v20.2.2. Please see the `supported platforms `_ for current and planned support in Ceph. MDS (Metadata Server) --------------------- From fe6af7fc85d47ad25fb77e1728b0eb100c208129 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Tue, 24 Mar 2026 17:54:22 +0000 Subject: [PATCH 264/596] doc: add PendingReleaseNotes entry for rgw multisite DNS endpoint resolution Documents the new rgw_rest_conn_connect_to_resolved_ips feature that enables RGW to resolve HTTP endpoints for RGW services such as multisite, into all IP addresses and distribute requests across them using round-robin with per-IP health tracking, supporting DNS service discovery deployments without external load balancers. Signed-off-by: Oguzhan Ozmen --- PendingReleaseNotes | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 2eb6e6be2e8..f53c1eac16c 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -100,6 +100,17 @@ than instantly aborting I/O and returning an error. It's possible to switch from ``RBD_LOCK_MODE_EXCLUSIVE`` to ``RBD_LOCK_MODE_EXCLUSIVE_TRANSIENT`` policy and vice versa even if the lock is already held. +* RGW: In multisite deployments, zone endpoints configured in the zonegroup + (e.g. ``https://zone-a.example.com``) can now be resolved to all IP addresses + returned by DNS, with inter-zone traffic distributed across them using + round-robin and per-IP health tracking. This enables DNS-based service discovery + for inter-zone traffic without an external load balancer. This feature is disabled + by default: to opt in, set ``rgw_rest_conn_connect_to_resolved_ips = true``. The + per-IP retry-after-failure timeout is controlled by ``rgw_rest_conn_ip_fail_timeout_secs`` + (default: 2 seconds). Deployments that work around libcurl's single-address behavior + by repeating the same endpoint multiple times in zone configuration should review that + setup before enabling, as round-robin distribution makes such duplication unnecessary. + * OSD: A health warning is reported when BlueFS usage exceeds the configured ratio of the main OSD data device size. This warning is informational and can be muted with: From c7c62383d8d2d27aca82d9bfa0a32b809817d7b4 Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Wed, 10 Jun 2026 10:51:33 +0530 Subject: [PATCH 265/596] mgr/dashboard: add custom filtering rules to the table ``` ``` Fixes: https://tracker.ceph.com/issues/77290 Signed-off-by: Nizamudeen A --- .../datatable/table/table.component.html | 98 +++++++-- .../datatable/table/table.component.scss | 20 +- .../datatable/table/table.component.spec.ts | 51 +++++ .../shared/datatable/table/table.component.ts | 194 ++++++++++++++++-- .../src/app/shared/enum/icons.enum.ts | 3 +- .../shared/models/cd-table-column-filter.ts | 17 ++ 6 files changed, 354 insertions(+), 29 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index 1460f9ca77a..f5a7297b167 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -51,7 +51,7 @@ - @if (columnFilters.length !== 0) { + @if (columnFilters.length !== 0 || customFilter) {
+
+ Filters + + + +
+ @if (columnFilters.length !== 0) { @for (filter of columnFilters; track filter.column.name; let i = $index) {
Filter @@ -98,7 +107,7 @@ @@ -112,18 +121,83 @@
+ }} + @if (customFilter) { + @for (filter of stagedCustomFilters; track filter.id; let i = $index) { + @if (typeof customFilter === 'string') { +
+ Filter {{ customFilter }}: +
+ } +
+
+ Key + + +
+ +
+ Value + + +
+ + @if (i !== 0) { +
+ + + +
+ } +
} -
+ @if (stagedCustomFilters.length < 5) { +
+
+ +
+
+ } + } +
+ + -
} @@ -183,13 +257,13 @@ class="align-items-center" cdsStack="horizontal" [gap]="2"> - @for (filter of activeFilters | slice:0:3; track filter.column.name) { + @for (filter of activeFilters | slice:0:3; track filter.id) { @if (filter.value) { - {{ filter.column.name }}={{ filter.value.formatted }} + {{ filter.name }}={{ filter.value }} } } @@ -203,12 +277,12 @@
- @for (filter of activeFilters | slice:3; track filter.column.name) { + @for (filter of activeFilters | slice:3; track filter.id) { - {{ filter.column.name }}={{ filter.value.formatted }} + {{ filter.name }}={{ filter.value }} }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss index 611a3b9e93f..a4357ed13b9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss @@ -111,8 +111,22 @@ tr:hover .edit-btn { white-space: nowrap; } -.filter-wrapper { - display: inline-flex; +.filter--header { align-items: center; - gap: 0.5rem; + border-bottom: 1px solid var(--cds-ui-03); + display: flex; + justify-content: space-between; + padding: var(--cds-spacing-04); +} + +.filter--footer { + display: flex; + margin-top: var(--cds-spacing-08); + width: 100%; + + button { + flex: 1; + justify-content: center; + max-width: 50%; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts index 889439dcc6e..e92aafaf71d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts @@ -280,6 +280,57 @@ describe('TableComponent', () => { }); }); + describe('test custom filtering', () => { + beforeEach(() => { + component.customFilter = true; + component.customFilters = []; + component.stagedCustomFilters = []; + spyOn(component.customFilterChange, 'emit'); + spyOn(component, 'updateFilter'); + }); + + it('should toggle popover and add an empty rule', () => { + component.toggleFilterPopover(); + + expect(component.openFilterPopover).toBe(true); + expect(component.stagedCustomFilters.length).toBe(1); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + + // toggling again should close but not add a new rule + component.toggleFilterPopover(); + expect(component.openFilterPopover).toBe(false); + expect(component.stagedCustomFilters.length).toBe(1); + }); + + it('should manually add new custom filters', () => { + component.addCustomFilter(); + component.addCustomFilter(); + expect(component.stagedCustomFilters.length).toBe(2); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + expect(component.stagedCustomFilters[1]).toEqual({ id: 1, key: '', value: '' }); + }); + + it('should remove a filter by its id', () => { + component.addCustomFilter(); + component.addCustomFilter(); + component.addCustomFilter(); + + component.removeCustomFilter(1); + expect(component.stagedCustomFilters.length).toBe(2); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + expect(component.stagedCustomFilters[1]).toEqual({ id: 2, key: '', value: '' }); + }); + + it('should emit custom filters on submit', () => { + component.addCustomFilter(); + component.stagedCustomFilters[0] = { id: 0, key: 'foo', value: 'bar' }; + component.onSubmitFilter(); + expect(component.customFilterChange.emit).toHaveBeenCalledWith([ + { id: 0, key: 'foo', value: 'bar' } + ]); + }); + }); + describe('test search', () => { const expectSearch = (keyword: string, expectedResult: object[]) => { component.search = keyword; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 7d4c8284784..a261212f324 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -26,10 +26,12 @@ import { Icons, IconSize, EMPTY_STATE_IMAGE } from '~/app/shared/enum/icons.enum import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { + CdTableActiveColumnFilter, CdTableColumnFilter, CdTableColumnFilterOption, CdTableColumnSelectedFilter, - CdTableColumnStagedFilter + CdTableColumnStagedFilter, + CdTableCustomColumnFilter } from '~/app/shared/models/cd-table-column-filter'; import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; @@ -197,6 +199,14 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr @Input() extraFilterableColumns: CdTableColumn[] = []; + /* + Used to set custom filters on the table. + boolean - if you just want to enable custom filters with default settings + string - if you want to enable custom filters and set a specific col prop to be used as filter name + */ + @Input() + customFilter: boolean | string = false; + @Input() status = new TableStatus(); @@ -293,6 +303,9 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr @Output() isCellEditingEvent = new EventEmitter(); + @Output() + customFilterChange = new EventEmitter(); + /** * Use this variable to access the selected row(s). */ @@ -428,10 +441,15 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr columnFilters: CdTableColumnFilter[] = []; selectedFilter: CdTableColumnFilter; get columnFiltered(): boolean { - return _.some(this.columnFilters, (filter: any) => { + const hasStandardFilters = _.some(this.columnFilters, (filter: any) => { return filter.value !== undefined; }); + const hasCustomFilters = this.customFilters && this.customFilters.length > 0; + + return hasStandardFilters || hasCustomFilters; } + customFilters: CdTableCustomColumnFilter[] = []; + private nextFilterId = 0; private previousRows = new Map(); private debouncedSearch = this.reloadData.bind(this); @@ -442,9 +460,32 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr openFilterPopover = false; stagedFilters: CdTableColumnStagedFilter = {}; selectedFilters: CdTableColumnSelectedFilter = {}; + stagedCustomFilters: CdTableCustomColumnFilter[] = []; get activeFilters() { - return this.columnFilters.filter((filter) => filter.value); + const standard: CdTableActiveColumnFilter[] = this.columnFilters + .filter((filter) => filter.value) + .map((filter) => ({ + isCustom: false, + id: `std_${filter.column.name}`, + name: filter.column.name, + value: filter.value.formatted, + original: filter + })); + + const custom: CdTableActiveColumnFilter[] = (this.customFilters || []).map((filter) => ({ + isCustom: true, + id: `cst_${filter.id}`, + name: filter.key, + value: filter.value, + original: filter + })); + + return [...standard, ...custom]; + } + + get isApplyFilterDisabled(): boolean { + return this.stagedCustomFilters.some((filter) => !filter.key.trim() || !filter.value.trim()); } constructor( @@ -769,6 +810,27 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr toggleFilterPopover() { this.openFilterPopover = !this.openFilterPopover; + + if (this.openFilterPopover) { + this.stagedCustomFilters = _.cloneDeep(this.customFilters || []); + + if (this.customFilter && this.customFilters.length === 0) { + this.addCustomFilter(); + } + } + } + + addCustomFilter() { + this.stagedCustomFilters = [ + ...this.stagedCustomFilters, + { id: this.nextFilterId++, key: '', value: '' } + ]; + } + + removeCustomFilter(idToRemove: number) { + this.stagedCustomFilters = this.stagedCustomFilters.filter( + (filter) => filter.id !== idToRemove + ); } initColumnFilters() { @@ -852,21 +914,41 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr this.selectedFilter = filter; } }); - this.stagedFilters = {}; + + if (this.customFilter) { + this.customFilters = this.stagedCustomFilters.filter( + (customFilter: CdTableCustomColumnFilter) => + customFilter.key.trim() !== '' && customFilter.value.trim() !== '' + ); + this.customFilterChange.emit(this.customFilters); + } + this.updateFilter(); this.openFilterPopover = false; } - onRemoveFilter(filter: CdTableColumnFilter) { - const filterName = filter.column.name; - filter.value = undefined; - this.selectedFilters[filterName] = undefined; - delete this.stagedFilters[filterName]; - if (this.selectedFilter?.column.name === filterName) { - this.selectedFilter = undefined; + onRemoveFilter(filter: CdTableActiveColumnFilter) { + if (filter.isCustom) { + this.customFilters = (this.customFilters || []).filter( + (customFilter: CdTableCustomColumnFilter) => customFilter.id !== filter.original.id + ); + this.stagedCustomFilters = this.stagedCustomFilters.filter( + (customFilter: CdTableCustomColumnFilter) => customFilter.id !== filter.original.id + ); + + this.customFilterChange.emit(this.customFilters); + this.updateFilter(); + } else { + const filterName = filter.original.name; + filter.original.value = undefined; + this.selectedFilters[filterName] = undefined; + delete this.stagedFilters[filterName]; + if (this.selectedFilter?.column.name === filterName) { + this.selectedFilter = undefined; + } + this.updateFilter(); } - this.updateFilter(); } doColumnFiltering() { @@ -897,6 +979,85 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr dataOut = [...dataOut, ...parts[1]]; }); + if (this.customFilter && this.customFilters.length > 0) { + this.customFilters.forEach((customFilter: CdTableCustomColumnFilter) => { + const matchingColumn = this.localColumns.find( + (col: CdTableColumn) => + col.name && col.name.toLowerCase() === customFilter.key.trim().toLowerCase() + ); + const resolvedProp = matchingColumn + ? (matchingColumn.prop as string) + : customFilter.key.trim(); + const displayKeyName = matchingColumn ? matchingColumn.name : customFilter.key; + + appliedFilters.push({ + name: displayKeyName, + prop: typeof this.customFilter === 'string' ? this.customFilter : resolvedProp, + value: { raw: customFilter.value, formatted: customFilter.value } + }); + }); + + // grouping filters by keys so we can filter the data with all the given filters + const groupedFilters = _.groupBy( + this.customFilters, + (customFilter: CdTableCustomColumnFilter) => customFilter.key.trim().toLowerCase() + ); + const filterGroups = Object.values(groupedFilters); + + const parts = _.partition(data, (row) => { + return filterGroups.every((group) => { + return group.some((customFilter: CdTableCustomColumnFilter) => { + const matchingColumn = this.localColumns.find( + (col: CdTableColumn) => + col.name && col.name.toLowerCase() === customFilter.key.trim().toLowerCase() + ); + + const filterKey = matchingColumn + ? (matchingColumn.prop as string) + : customFilter.key.trim(); + const rawKey = customFilter.key.trim(); + const filterValue = customFilter.value; + + if (_.has(row, filterKey)) { + if (`${_.get(row, filterKey)}` === filterValue) return true; + } + + // this is only when the customFilter is for a column that has a + // key-value pair for its values. + // eg: tags of objects in s3. + if (typeof this.customFilter === 'string' && _.has(row, this.customFilter)) { + const nestedData = _.get(row, this.customFilter); + + // when the data is an array of objects or strings, + // we need to check each element in the array. + if (_.isArray(nestedData)) { + return _.some(nestedData, (key) => { + if (_.isObject(key) && _.has(key, rawKey)) { + return `${(key as any)[rawKey]}` === filterValue; + } + if (typeof key === 'string') { + return key === `${rawKey}:${filterValue}` || key === `${rawKey}=${filterValue}`; + } + return false; + }); + } + + // if object is a simple dict + if (_.isObject(nestedData) && !_.isArray(nestedData)) { + if (_.has(nestedData, rawKey)) { + if (`${(nestedData as any)[rawKey]}` === filterValue) return true; + } + } + } + return false; + }); + }); + }); + + data = parts[0]; + dataOut = [...dataOut, ...parts[1]]; + } + this.columnFiltersChanged.emit({ filters: appliedFilters, data: data, @@ -1340,6 +1501,13 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr filter.value = undefined; }); this.selectedFilter = _.first(this.columnFilters); + this.customFilters = []; + this.stagedCustomFilters = []; + + if (this.customFilter) { + this.addCustomFilter(); + } + this.updateFilter(); this.initSelectedColumnFilters(); } @@ -1357,7 +1525,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr } this.rows = this.data; } else { - let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data; + let rows = this.doColumnFiltering(); if (this.search.length > 0 && rows?.length) { const columns = this.localColumns.filter( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 60de24cc8ed..c5190555b6a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -174,7 +174,8 @@ export const ICON_TYPE = { leftArrow: 'caret--left', rightArrow: 'caret--right', locked: 'locked', - cloudMonitoring: 'cloud--monitoring' + cloudMonitoring: 'cloud--monitoring', + trash: 'trash-can' } as const; export const EMPTY_STATE_IMAGE = { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts index d4d45ac91ba..c02817e9265 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts @@ -4,6 +4,8 @@ export interface CdTableColumnFilter { column: CdTableColumn; options: CdTableColumnFilterOption[]; // possible options of a filter value?: CdTableColumnFilterOption; // selected option + id?: string; + name?: string; } export interface CdTableColumnStagedFilter { @@ -18,3 +20,18 @@ export interface CdTableColumnFilterOption { raw: string; formatted: string; } + +export interface CdTableCustomColumnFilter { + id: number; + key: string; + value: string; + name?: string; +} + +export interface CdTableActiveColumnFilter { + id: string; + name: string; + value: string; + isCustom: boolean; + original: CdTableColumnFilter | CdTableCustomColumnFilter; +} From 0419e34f8dcdd5cfacb72fee0f202996210d3548 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sun, 24 May 2026 15:27:04 +0800 Subject: [PATCH 266/596] crimson/os/seastore/transaction_manager: prevent paddr from being passed across coroutines. This is because rewrite transactions no longer invalidates other transactions, so if paddr get passed across coroutines, it may become outdated. Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/transaction_manager.h | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/crimson/os/seastore/transaction_manager.h b/src/crimson/os/seastore/transaction_manager.h index 793d03bee98..3e22411f080 100644 --- a/src/crimson/os/seastore/transaction_manager.h +++ b/src/crimson/os/seastore/transaction_manager.h @@ -1411,10 +1411,10 @@ private: } #endif + co_await pin.co_refresh(); if (pin.is_indirect()) { SUBDEBUGT(seastore_tm, "{} into {} remaps ...", t, pin, remaps.size()); - co_await pin.co_refresh(); pin = co_await complete_mapping(t, std::move(pin)); } else { laddr_t original_laddr = pin.get_key(); @@ -1425,7 +1425,6 @@ private: ceph_assert(!pin.is_clone()); TCachedExtentRef extent; - co_await pin.co_refresh(); if (full_extent_integrity_check) { SUBTRACET(seastore_tm, "{} reading pin...", t, pin); // read the entire extent from disk (See: pin_to_extent) @@ -1447,7 +1446,7 @@ private: auto &child_pos = ret.get_child_pos(); auto laddr = pin.get_key(); std::ignore = cache->retire_absent_extent_addr_by_type( - t, laddr, original_paddr, original_len, pin.get_extent_type(), + t, laddr, pin.get_val(), original_len, pin.get_extent_type(), [&child_pos, laddr](auto &extent) mutable { auto lextent = extent.template cast(); assert(extent.is_logical()); @@ -1473,7 +1472,7 @@ private: SUBDEBUGT(seastore_tm, "extent fully loaded...", t); ceph_assert(extent->is_data_stable()); ceph_assert(extent->get_length() >= original_len); - ceph_assert(extent->get_paddr() == original_paddr); + ceph_assert(extent->get_paddr() == pin.get_val()); original_bptr = extent->get_bptr(); } if (extent) { @@ -1485,7 +1484,7 @@ private: auto remap_offset = remap.offset; auto remap_len = remap.len; auto remap_laddr = (original_laddr + remap_offset).checked_to_laddr(); - auto remap_paddr = original_paddr.add_offset(remap_offset); + auto remap_paddr = pin.get_val().add_offset(remap_offset); SUBDEBUGT(seastore_tm, "remap direct pin into {}~0x{:x} {} ...", t, remap_laddr, remap_len, remap_paddr); ceph_assert(remap_len < original_len); From af137a0d86678fe868a5d8339d7c059eadef107a Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sun, 24 May 2026 16:45:01 +0800 Subject: [PATCH 267/596] crimson/os/seastore/lba: avoid paddr from crossing coroutines Previously, LBAManager::remap_mappings() works as follows: 1. get the mapping's val; 2. remove the mapping; 3. insert the remapped mappings. With pessimistic cc in place, during the above step 2 and 3, a rewrite transaction that modifies the same mapping might be committed and miss the pending lba leaf nodes because it doesn't contain the mapping at the time. This commit change the above workflow as follows: 1. replace the mapping with the first remapped mapping; 2. insert the remaining remapped mappings. Note that all the remaining mapped mappings' paddrs are calculated based on the mapping before it in the same coroutine as the insertion, which means it'll always see the modification of a background rewrite transaction. Signed-off-by: Xuehan Xu --- .../os/seastore/btree/fixed_kv_btree.h | 83 +++++++++++++++++++ .../os/seastore/lba/btree_lba_manager.cc | 70 ++++++++++------ src/crimson/os/seastore/lba/lba_btree_node.h | 18 ++++ src/crimson/os/seastore/linked_tree_node.h | 4 +- 4 files changed, 147 insertions(+), 28 deletions(-) diff --git a/src/crimson/os/seastore/btree/fixed_kv_btree.h b/src/crimson/os/seastore/btree/fixed_kv_btree.h index d1891e1441a..b701062610a 100644 --- a/src/crimson/os/seastore/btree/fixed_kv_btree.h +++ b/src/crimson/os/seastore/btree/fixed_kv_btree.h @@ -1135,6 +1135,89 @@ public: return iter; } + /** + * replace + * + * Replace the entry pointed by iter with the key and val. key + * must be within the range iter.get_key()~iter.get_length() + * + * @param c [in] op context + * @param iter [in] iterator to element to update, must not be end + * @param key [in] key with which to replace + * @param val [in] val with which to replace + * @return iterator to newly replaced element + */ + using replace_iertr = base_iertr; + using replace_ret = replace_iertr::future; + using get_val_func_t = std::function; + replace_ret replace( + op_context_t c, + iterator iter, + node_key_t key, + get_val_func_t func, + BaseChildNode *child) + { + LOG_PREFIX(FixedKVBtree::replace); + SUBTRACET( + seastore_fixedkv_tree, + "replace element at {} with {}", + c.trans, + iter.is_end() ? min_max_t::max : iter.get_key(), + key); + if (likely(iter.leaf.node->get_meta().end > key)) { + // the replacing key is also within the range of the current + // leaf node, so do the replacing directly + if (!iter.leaf.node->is_mutable()) { + CachedExtentRef mut = c.cache.duplicate_for_write( + c.trans, iter.leaf.node + ); + iter.leaf.node = mut->cast(); + } + ++(get_tree_stats(c.trans).num_updates); + iter.leaf.node->replace( + iter.leaf.node->iter_idx(iter.leaf.pos), + key, + func()); + if constexpr (std::is_base_of_v< + ParentNode, leaf_node_t>) { + if (child) { + iter.leaf.node->update_child_ptr(iter.leaf.pos, child); + } + } + co_return iter; + } + // the replacing key fall beyond the end of the current leaf + // node, insert it into the next leaf node and remove the + // original key + auto niter = co_await iter.next(c); + assert(!niter.is_end()); + assert(niter.get_key() > key); + co_await handle_split(c, niter); + if (!niter.leaf.node->is_mutable()) { + CachedExtentRef mut = c.cache.duplicate_for_write( + c.trans, niter.leaf.node + ); + niter.leaf.node = mut->cast(); + } + auto it = typename leaf_node_t::const_iterator( + niter.leaf.node.get(), niter.leaf.pos); + assert(key >= niter.leaf.node->get_meta().begin && + key < niter.leaf.node->get_meta().end); + niter.leaf.node->insert(it, key, func()); + if constexpr (std::is_base_of_v< + ParentNode, leaf_node_t>) { + niter.leaf.node->insert_child_ptr( + niter.leaf.pos, child, niter.leaf.node->get_size() - 1); + } + (void)child; + // TODO: the keys pointed by iter and niter are overlapped which + // is against the invariant of the lba/backref tree, but since + // remove directly removes the iter, it wouldn't cause any issue + // for the time being. + niter = co_await remove(c, std::move(iter)); + assert(niter.get_key() == key); + co_return niter; + } /** * remove diff --git a/src/crimson/os/seastore/lba/btree_lba_manager.cc b/src/crimson/os/seastore/lba/btree_lba_manager.cc index 0d71c0969de..6a42f5ae662 100644 --- a/src/crimson/os/seastore/lba/btree_lba_manager.cc +++ b/src/crimson/os/seastore/lba/btree_lba_manager.cc @@ -1086,38 +1086,54 @@ BtreeLBAManager::remap_mappings( (orig_val.pladdr.is_paddr() && orig_val.pladdr.get_paddr().is_absolute())); auto type = cursor->get_extent_type(); - cursor = co_await update_mapping_refcount( - c.trans, cursor, -1); - iter = btree.make_partial_iter(c, *cursor); + auto off = 0; + auto last_iter = iter; for (auto &remap : remaps) { assert(remap.offset + remap.len <= orig_len); assert((bool)remap.extent == !orig_indirect); - lba_map_val_t val = orig_val; auto new_key = (orig_laddr + remap.offset).checked_to_laddr(); - val.len = remap.len; - if (val.pladdr.is_laddr()) { - DEBUGT("{} + {:#x}", - t, - val.pladdr.get_local_clone_id(), - remap.offset); - } else { - auto paddr = val.pladdr.get_paddr(); - val.pladdr = paddr + remap.offset; - } - val.refcount = EXTENT_DEFAULT_REF_COUNT; - // Checksum will be updated when the committing the transaction - val.checksum = CRC_NULL; - val.type = type; + auto f = [&last_iter, &remap, &off, &t, FNAME, type] { + lba_map_val_t val = last_iter.get_val(); + auto cur_off = remap.offset - off; + if (val.pladdr.is_laddr()) { + DEBUGT("{} + {:#x}", + t, + val.pladdr.get_local_clone_id(), + remap.offset); + } else { + auto paddr = val.pladdr.get_paddr(); + val.pladdr = paddr + cur_off; + } + val.len = remap.len; + val.refcount = EXTENT_DEFAULT_REF_COUNT; + // Checksum will be updated when the committing the transaction + val.checksum = CRC_NULL; + val.type = type; + return val; + }; // committing the transaction - auto p = co_await btree.insert( - c, iter, new_key, std::move(val), - orig_indirect - ? get_reserved_ptr() - : remap.extent); - auto &[it, inserted] = p; - ceph_assert(inserted); - ret.push_back(it.get_cursor(c)); - iter = co_await it.next(c); + if (remap.offset == remaps.front().offset) { + iter = co_await btree.replace( + c, std::move(iter), new_key, + std::move(f), + orig_indirect + ? get_reserved_ptr() + : remap.extent); + ret.push_back(iter.get_cursor(c)); + iter = co_await iter.next(c); + } else { + auto p = co_await btree.insert( + c, iter, new_key, f(), + orig_indirect + ? get_reserved_ptr() + : remap.extent); + auto &[it, inserted] = p; + ceph_assert(inserted); + ret.push_back(it.get_cursor(c)); + last_iter = it; + iter = co_await it.next(c); + } + off = remap.offset; } co_await trans_intr::parallel_for_each( ret, diff --git a/src/crimson/os/seastore/lba/lba_btree_node.h b/src/crimson/os/seastore/lba/lba_btree_node.h index dd15c2a8cf2..cabb652bebf 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.h +++ b/src/crimson/os/seastore/lba/lba_btree_node.h @@ -148,6 +148,24 @@ struct LBALeafNode laddr_t addr, lba_map_val_t val) final; + void replace( + internal_const_iterator_t iter, + laddr_t pivot, + lba_map_val_t val) { + LOG_PREFIX(FixedKVInternalNode::replace); + SUBTRACE(seastore_fixedkv_tree, "trans.{}, pos {}, old key {}, key {}", + this->pending_for_transaction, + iter.get_offset(), + iter.get_key(), + pivot); + this->on_modify(); + return this->journal_replace( + iter, + pivot, + std::move(val), + maybe_get_delta_buffer()); + } + void remove(internal_const_iterator_t iter) final { LOG_PREFIX(LBALeafNode::remove); SUBTRACE(seastore_fixedkv_tree, "trans.{}, pos {}, key {}", diff --git a/src/crimson/os/seastore/linked_tree_node.h b/src/crimson/os/seastore/linked_tree_node.h index 3683f90972b..1b136c8a6a2 100644 --- a/src/crimson/os/seastore/linked_tree_node.h +++ b/src/crimson/os/seastore/linked_tree_node.h @@ -426,7 +426,9 @@ public: void update_child_ptr(btreenode_pos_t pos, BaseChildNode* child) { children[pos] = child; - set_child_ptracker(child); + if (!is_reserved_ptr(child)) { + set_child_ptracker(child); + } } // copy dests points from a stable node back to its pending nodes From cb5ea45c28fd20d996d5875dbdb7148eb728d3a6 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 20:19:11 +0800 Subject: [PATCH 268/596] crimson/osd: fix malformed debug log format strings a few debug() calls had format strings that did not match their arguments. with the seastar logger formatting at runtime these only threw an fmt error at the matching log level, or went unnoticed: - pg_backend.cc: "{}: object does not exist: {}" had two placeholders but one argument; drop the stray leading "{}: ". - pg.cc do_recover_missing(): the "need to wait for recovery ... version {}" message never passed the eversion_t; add it. - client_request.cc: a DEBUGDPP() carried an extra leading "{}: " on top of the prefix the macro already injects (also fix the "rwoedered" typo). Signed-off-by: Kefu Chai --- src/crimson/osd/osd_operations/client_request.cc | 2 +- src/crimson/osd/pg.cc | 2 +- src/crimson/osd/pg_backend.cc | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/crimson/osd/osd_operations/client_request.cc b/src/crimson/osd/osd_operations/client_request.cc index d470d8bb35e..f1a5d400496 100644 --- a/src/crimson/osd/osd_operations/client_request.cc +++ b/src/crimson/osd/osd_operations/client_request.cc @@ -624,7 +624,7 @@ bool ClientRequest::is_misdirected_replica_read(const PG& pg) const flags & CEPH_OSD_FLAG_BALANCE_READS || flags & CEPH_OSD_FLAG_LOCALIZE_READS) { if (op_info.rwordered()) { - DEBUGDPP("{}: dropping - rwoedered with balanced/localize read {}", pg, *this); + DEBUGDPP("dropping - reordered with balanced/localize read {}", pg, *this); return true; } if (!op_info.may_read()) { diff --git a/src/crimson/osd/pg.cc b/src/crimson/osd/pg.cc index f79e8272d99..dfd756f9908 100644 --- a/src/crimson/osd/pg.cc +++ b/src/crimson/osd/pg.cc @@ -760,7 +760,7 @@ PG::interruptible_future PG::do_recover_missing( } DEBUGDPP( "reqid {} need to wait for recovery, {} version {}", - *this, reqid, soid); + *this, reqid, soid, ver); if (recovery_backend->is_recovering(soid)) { DEBUGDPP( "reqid {} object {} version {}, already recovering", diff --git a/src/crimson/osd/pg_backend.cc b/src/crimson/osd/pg_backend.cc index 6a2e57d0992..e17c7aa9d41 100644 --- a/src/crimson/osd/pg_backend.cc +++ b/src/crimson/osd/pg_backend.cc @@ -1451,7 +1451,7 @@ PGBackend::omap_get_keys( object_stat_sum_t& delta_stats) const { if (!os.exists || os.oi.is_whiteout()) { - logger().debug("{}: object does not exist: {}", os.oi.soid); + logger().debug("object does not exist: {}", os.oi.soid); co_await ll_read_ierrorator::future<>(crimson::ct_error::enoent::make()); } std::string start_after; @@ -1545,7 +1545,7 @@ PGBackend::omap_cmp( object_stat_sum_t& delta_stats) const { if (!os.exists || os.oi.is_whiteout()) { - logger().debug("{}: object does not exist: {}", os.oi.soid); + logger().debug("object does not exist: {}", os.oi.soid); return crimson::ct_error::enoent::make(); } @@ -1581,7 +1581,7 @@ PGBackend::omap_get_vals( object_stat_sum_t& delta_stats) const { if (!os.exists || os.oi.is_whiteout()) { - logger().debug("{}: object does not exist: {}", os.oi.soid); + logger().debug("object does not exist: {}", os.oi.soid); co_await ll_read_ierrorator::future<>(crimson::ct_error::enoent::make()); } std::string start_after; @@ -1778,7 +1778,7 @@ PGBackend::omap_clear( object_stat_sum_t& delta_stats) { if (!os.exists || os.oi.is_whiteout()) { - logger().debug("{}: object does not exist: {}", os.oi.soid); + logger().debug("object does not exist: {}", os.oi.soid); return crimson::ct_error::enoent::make(); } if (!os.oi.is_omap()) { From d5d2ff2e49352314734561ad1129a33d62bf36a3 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 20:19:11 +0800 Subject: [PATCH 269/596] crimson, osd: declare fmt formatters before they are used the seastar logger checks log format strings with a consteval call at the use site, which instantiates fmt::formatter there. a specialization declared at the bottom of a header, after inline methods that already log T, is then "explicit specialization after instantiation". move the offending specializations ahead of the type: PG, LBALeafNode, DummyNodeExtent and WatchTimeoutRequest get a forward declaration plus the formatter before the class; overwrite_range_t, data_t and edge_t in object_data_handler.cc move ahead of the functions that log them. while here, trim_data_reservation() formatted a laddr_t base with 0x{:x}, but laddr_t's formatter takes no spec, so use {}. osd_types_fmt.h includes msg/msg_fmt.h: formatter formats req_id.name (an entity_name_t), so it depends on that type's formatter and must pull it in. otherwise logging a reqid instantiates formatter before msg_fmt.h is seen. Signed-off-by: Kefu Chai --- src/crimson/os/seastore/lba/lba_btree_node.h | 12 ++++++++++- .../os/seastore/object_data_handler.cc | 21 ++++++++++++------- .../staged-fltree/node_extent_manager/dummy.h | 14 +++++++++---- src/crimson/osd/pg.h | 16 ++++++++++---- src/crimson/osd/watch.cc | 14 +++++++++---- src/osd/osd_types_fmt.h | 5 +++++ 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/crimson/os/seastore/lba/lba_btree_node.h b/src/crimson/os/seastore/lba/lba_btree_node.h index dd15c2a8cf2..de7e18d8e6e 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.h +++ b/src/crimson/os/seastore/lba/lba_btree_node.h @@ -7,6 +7,7 @@ #include #include +#include #include "include/buffer.h" @@ -24,6 +25,16 @@ namespace crimson::os::seastore { class LogicalChildNode; } +namespace crimson::os::seastore::lba { +struct LBALeafNode; +} + +// declared ahead of the struct so the consteval {fmt} check sees it from +// LBALeafNode's own inline logging methods. +#if FMT_VERSION >= 90000 +template <> struct fmt::formatter : fmt::ostream_formatter {}; +#endif + namespace crimson::os::seastore::lba { using LBANode = FixedKVNode; @@ -470,6 +481,5 @@ using LBACursorRef = boost::intrusive_ptr; template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; -template <> struct fmt::formatter : fmt::ostream_formatter {}; template <> struct fmt::formatter : fmt::ostream_formatter {}; #endif diff --git a/src/crimson/os/seastore/object_data_handler.cc b/src/crimson/os/seastore/object_data_handler.cc index a586a606d68..a71de486352 100644 --- a/src/crimson/os/seastore/object_data_handler.cc +++ b/src/crimson/os/seastore/object_data_handler.cc @@ -4,10 +4,23 @@ #include #include +#include + #include "crimson/common/log.h" #include "crimson/os/seastore/object_data_handler.h" +// declared ahead of the logging functions below so the consteval {fmt} +// check can see them at the call sites. +#if FMT_VERSION >= 90000 +template <> struct fmt::formatter + : fmt::ostream_formatter {}; +template <> struct fmt::formatter + : fmt::ostream_formatter {}; +template <> struct fmt::formatter + : fmt::ostream_formatter {}; +#endif + namespace { seastar::logger& logger() { return crimson::get_logger(ceph_subsys_seastore_odata); @@ -1881,11 +1894,3 @@ ObjectDataHandler::rename(context_t ctx) } // namespace crimson::os::seastore -#if FMT_VERSION >= 90000 -template <> struct fmt::formatter - : fmt::ostream_formatter {}; -template <> struct fmt::formatter - : fmt::ostream_formatter {}; -template <> struct fmt::formatter - : fmt::ostream_formatter {}; -#endif diff --git a/src/crimson/os/seastore/onode_manager/staged-fltree/node_extent_manager/dummy.h b/src/crimson/os/seastore/onode_manager/staged-fltree/node_extent_manager/dummy.h index 6852ec0298c..3a1610d1578 100644 --- a/src/crimson/os/seastore/onode_manager/staged-fltree/node_extent_manager/dummy.h +++ b/src/crimson/os/seastore/onode_manager/staged-fltree/node_extent_manager/dummy.h @@ -11,12 +11,22 @@ #include "crimson/os/seastore/onode_manager/staged-fltree/node_extent_manager.h" +#include + /** * dummy.h * * Dummy backend implementations for test purposes. */ +namespace crimson::os::seastore::onode { +class DummyNodeExtent; +} + +#if FMT_VERSION >= 90000 +template <> struct fmt::formatter : fmt::ostream_formatter {}; +#endif + namespace crimson::os::seastore::onode { class DummySuper final: public Super { @@ -191,7 +201,3 @@ class DummyNodeExtentManager final: public NodeExtentManager { }; } - -#if FMT_VERSION >= 90000 -template <> struct fmt::formatter : fmt::ostream_formatter {}; -#endif diff --git a/src/crimson/osd/pg.h b/src/crimson/osd/pg.h index 6e5e47c591c..d44f4455a01 100644 --- a/src/crimson/osd/pg.h +++ b/src/crimson/osd/pg.h @@ -11,6 +11,8 @@ #include #include +#include + #include "common/dout.h" #include "common/ostream_temp.h" #include "include/interval_set.h" @@ -67,6 +69,16 @@ namespace crimson::os { class FuturizedStore; } +namespace crimson::osd { +class PG; +} + +// declared ahead of the class so the consteval {fmt} check can see it from +// PG's own inline logging methods (which format *this) above the class. +#if FMT_VERSION >= 90000 +template <> struct fmt::formatter : fmt::ostream_formatter {}; +#endif + namespace crimson::osd { class OpsExecuter; class SnapTrimEvent; @@ -1356,7 +1368,3 @@ struct PG::do_osd_ops_params_t { std::ostream& operator<<(std::ostream&, const PG& pg); } - -#if FMT_VERSION >= 90000 -template <> struct fmt::formatter : fmt::ostream_formatter {}; -#endif diff --git a/src/crimson/osd/watch.cc b/src/crimson/osd/watch.cc index d8db6ba628e..08534540941 100644 --- a/src/crimson/osd/watch.cc +++ b/src/crimson/osd/watch.cc @@ -11,6 +11,8 @@ #include "messages/MWatchNotify.h" +#include + namespace { seastar::logger& logger() { @@ -18,6 +20,14 @@ namespace { } } +namespace crimson::osd { +class WatchTimeoutRequest; +} + +#if FMT_VERSION >= 90000 +template <> struct fmt::formatter : fmt::ostream_formatter {}; +#endif + namespace crimson::osd { // a watcher can remove itself if it has not seen a notification after a period of time. @@ -348,7 +358,3 @@ void Notify::do_notify_timeout() } } // namespace crimson::osd - -#if FMT_VERSION >= 90000 -template <> struct fmt::formatter : fmt::ostream_formatter {}; -#endif diff --git a/src/osd/osd_types_fmt.h b/src/osd/osd_types_fmt.h index 0e36755782a..402453bc2e9 100644 --- a/src/osd/osd_types_fmt.h +++ b/src/osd/osd_types_fmt.h @@ -8,6 +8,11 @@ #include "common/hobject.h" #include "include/types_fmt.h" +// formatter below formats req_id.name (entity_name_t), so its +// formatter must be visible here, otherwise it gets instantiated before +// msg_fmt.h is pulled in by a later include (explicit-specialization-after- +// instantiation under compile-time {fmt} checking). +#include "msg/msg_fmt.h" #include "osd/osd_types.h" #include #include From 846f9294c6ee5b13ed0194a9fabea79023ec0549 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 20:25:45 +0800 Subject: [PATCH 270/596] crimson/net: log io_state_t out of line in shard_states_t try_enter_out_dispatching()'s impossible default case logs io_state_t, which would instantiate fmt::formatter inside IOHandler, before the formatter (declarable only after the class, since io_state_t is nested) is visible. move just that abort path to io_handler.cc, where the formatter is in scope, and keep the hot switch inline. Signed-off-by: Kefu Chai --- src/crimson/net/io_handler.cc | 8 ++++++++ src/crimson/net/io_handler.h | 11 +++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/crimson/net/io_handler.cc b/src/crimson/net/io_handler.cc index c08bdc87422..df55f1b6f07 100644 --- a/src/crimson/net/io_handler.cc +++ b/src/crimson/net/io_handler.cc @@ -1293,6 +1293,14 @@ IOHandler::shard_states_t::notify_out_dispatching_stopped( } } +void +IOHandler::shard_states_t::abort_wrong_io_state(SocketConnection &conn) +{ + logger().error("{} try_enter_out_dispatching() got wrong io_state {}", + conn, io_state); + ceph_abort_msg("impossible"); +} + seastar::future<> IOHandler::shard_states_t::wait_io_exit_dispatching() { diff --git a/src/crimson/net/io_handler.h b/src/crimson/net/io_handler.h index 9cb489af941..c86419c76b8 100644 --- a/src/crimson/net/io_handler.h +++ b/src/crimson/net/io_handler.h @@ -326,13 +326,16 @@ public: // do not dispatch out return false; default: - crimson::get_logger(ceph_subsys_ms).error( - "{} try_enter_out_dispatching() got wrong io_state {}", - conn, io_state); - ceph_abort_msg("impossible"); + abort_wrong_io_state(conn); } } + // defined out of line: logging io_state_t here, inside IOHandler, would + // instantiate fmt::formatter before it is declared (it can + // only be declared after IOHandler). only this impossible-state path + // formats it, so keep the hot switch inline and move the abort out. + [[noreturn]] void abort_wrong_io_state(SocketConnection &conn); + void notify_out_dispatching_stopped( const char *what, SocketConnection &conn); From 863f65fdee51c02812257aeb8bfc6771b379c29d Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 20:34:05 +0800 Subject: [PATCH 271/596] crimson/osd, seastore: fix log argument mismatches in error paths two log calls in errorator continuations were missing an argument. the compile-time fmt check surfaced these as a confusing "call to immediate function ... is not a constant expression" deep in the errorator/composer machinery rather than a plain "too few arguments", because the consteval format failure escalates through the continuation templates: - shard_services.cc handle_pg_create_info(): "ignore pgid {}" never passed pgid. - node.cc: the read_extent error handler logged "{} -- addr={}, is_level_tail={}" without the leading error; pass the std::error_code and include for its formatter. Signed-off-by: Kefu Chai --- src/crimson/os/seastore/onode_manager/staged-fltree/node.cc | 4 +++- src/crimson/osd/shard_services.cc | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/crimson/os/seastore/onode_manager/staged-fltree/node.cc b/src/crimson/os/seastore/onode_manager/staged-fltree/node.cc index 9d78d87a8cd..6145018240f 100644 --- a/src/crimson/os/seastore/onode_manager/staged-fltree/node.cc +++ b/src/crimson/os/seastore/onode_manager/staged-fltree/node.cc @@ -7,6 +7,8 @@ #include #include +#include // for fmt::formatter + #include "common/likely.h" #include "crimson/common/utility.h" #include "crimson/os/seastore/logging.h" @@ -693,7 +695,7 @@ eagain_ifuture> Node::load( crimson::ct_error::all_same_way( [FNAME, c, addr, expect_is_level_tail](const auto &e) { ERRORT("{} -- addr={}, is_level_tail={}", - c.t, addr, expect_is_level_tail); + c.t, e, addr, expect_is_level_tail); ceph_abort(); return eagain_iertr::make_ready_future(); }) diff --git a/src/crimson/osd/shard_services.cc b/src/crimson/osd/shard_services.cc index bfb75e645ea..abeeb439c0b 100644 --- a/src/crimson/osd/shard_services.cc +++ b/src/crimson/osd/shard_services.cc @@ -933,7 +933,7 @@ seastar::future> ShardServices::handle_pg_create_info( const spg_t &pgid = info->pgid; if (!get_map()->is_up_acting_osd_shard(pgid, local_state.whoami) || !startmap->is_up_acting_osd_shard(pgid, local_state.whoami)) { - DEBUG("ignore pgid {}, doesn't exist anymore, discarding"); + DEBUG("ignore pgid {}, doesn't exist anymore, discarding", pgid); local_state.pg_map.pg_creation_canceled(pgid); return seastar::make_ready_future< std::tuple, OSDMapService::cached_map_t> From 1d18b19997d3bbcd4f7d82d8430a3d63986cea9f Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 16 Jun 2026 20:19:11 +0800 Subject: [PATCH 272/596] cmake: enable compile-time fmt format string checking seastar checks log format strings at compile time when Seastar_LOGGER_COMPILE_TIME_FMT is on; the option is gated on fmt_VERSION >= 9.0.0. ceph builds fmt via add_subdirectory(), which leaves the version in fmt's own FMT_VERSION and never sets the lowercase fmt_VERSION the option reads, so the check was silently off for crimson. read the version off the fmt target and expose it so the option turns on. Signed-off-by: Kefu Chai --- src/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a93a38d7501..a53365e1f7f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -446,6 +446,8 @@ else() set(BUILD_SHARED_LIBS ${old_BUILD_SHARED_LIBS}) unset(old_BUILD_SHARED_LIBS) include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/src/fmt/include") + get_target_property(FMT_VERSION fmt VERSION) + set(fmt_VERSION "${FMT_VERSION}") endif() # in osd/PeeringState.h, the number of elements in PeeringState::Active::reactions From 488c7fdf0940edadbbb2b38c5099cc0e9c9d2348 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Wed, 17 Jun 2026 15:48:07 +0530 Subject: [PATCH 273/596] mgr/nfs: read full conf object when removing export URL remove_obj() called ioctx.read() without a length, so only the first 8192 bytes of conf-nfs.* were read before write_full(). Removing exports from a large cluster truncated and corrupted the shared config object, leaving malformed entries such as '%url "rados:' while export objects were deleted. Fixes: https://tracker.ceph.com/issues/77463 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/nfs/rados_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pybind/mgr/nfs/rados_utils.py b/src/pybind/mgr/nfs/rados_utils.py index e8af2c71680..37e4ae683fb 100644 --- a/src/pybind/mgr/nfs/rados_utils.py +++ b/src/pybind/mgr/nfs/rados_utils.py @@ -72,7 +72,8 @@ class NFSRados: def remove_obj(self, obj: str, config_obj: str, should_notify: Optional[bool] = True) -> None: with self.rados.open_ioctx(self.pool) as ioctx: ioctx.set_namespace(self.namespace) - export_urls = ioctx.read(config_obj) + size, _ = ioctx.stat(config_obj) + export_urls = ioctx.read(config_obj, size) url = '%url "{}"\n\n'.format(self._make_rados_url(obj)) export_urls = export_urls.replace(url.encode('utf-8'), b'') ioctx.remove_object(obj) From 90b925d824a12164962c55615bb8b6ad87ab42df Mon Sep 17 00:00:00 2001 From: Nitzan Mordechai Date: Thu, 11 Jun 2026 11:15:59 +0000 Subject: [PATCH 274/596] osd-recovery-prio: race condition fix in TEST_recovery_pool_priority Use norecover to pause recovery ops while inspecting reservation state. Without it, recovery can complete before the admin-socket dump is captured, causing intermittent test failures Fixes: https://tracker.ceph.com/issues/77113 Signed-off-by: Nitzan Mordechai --- qa/standalone/osd/osd-recovery-prio.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/qa/standalone/osd/osd-recovery-prio.sh b/qa/standalone/osd/osd-recovery-prio.sh index 02b65f67a27..2d533a5df65 100755 --- a/qa/standalone/osd/osd-recovery-prio.sh +++ b/qa/standalone/osd/osd-recovery-prio.sh @@ -426,13 +426,19 @@ function TEST_recovery_pool_priority() { ceph pg dump pgs ERRORS=0 + # Pause recovery so reservations are held while we inspect them + ceph osd set norecover + ceph osd pool set $pool1 size 2 ceph osd pool set $pool2 size 2 # Wait for both PGs to be in recovering state ceph pg dump pgs - # Wait for recovery to start + # Wait for recovery to start on both PGs so reservations are + # created. If we check too early we may miss the reservation + # creation and if we wait too long the recovery may complete + # and reservations be removed. set -o pipefail count=0 while(true) @@ -446,6 +452,7 @@ function TEST_recovery_pool_priority() { if test "$count" -eq "10" then echo "Recovery never started on both PGs" + ceph osd unset norecover return 1 fi count=$(expr $count + 1) @@ -520,6 +527,8 @@ function TEST_recovery_pool_priority() { fi fi + ceph osd unset norecover + wait_for_clean || return 1 if [ $ERRORS != "0" ]; From a10f58df8cb95deae899f35562bd024ad0c2bae9 Mon Sep 17 00:00:00 2001 From: Miki Patel Date: Sun, 19 Apr 2026 11:59:40 +0530 Subject: [PATCH 275/596] rbd-mirror: prune obsolete primary mirror snapshots after relocation Previously, obsolete primary and demoted primary snapshots on the secondary cluster were not cleaned up immediately after relocation. Instead, old primary snapshots remained until a subsequent promote operation triggered their cleanup, while old demoted primary snapshots persisted until a later demote operation removed them. Adding changes for proactive cleanup of obsolete primary and demoted primary snapshots that are no longer required after relocation. Also adding test coverage to validate the cleanup behavior. Fixes: https://tracker.ceph.com/issues/76154 Signed-off-by: Miki Patel --- qa/workunits/rbd/rbd_mirror.sh | 54 ++++ qa/workunits/rbd/rbd_mirror_helpers.sh | 37 +++ .../snapshot/test_mock_Replayer.cc | 272 +++++++++++------- .../rbd_mirror/test_mock_ImageReplayer.cc | 10 +- src/tools/rbd_mirror/ImageReplayer.cc | 1 + src/tools/rbd_mirror/Types.h | 2 +- .../rbd_mirror/image_replayer/StateBuilder.h | 1 + .../image_replayer/journal/StateBuilder.cc | 1 + .../image_replayer/journal/StateBuilder.h | 1 + .../image_replayer/snapshot/Replayer.cc | 32 ++- .../image_replayer/snapshot/Replayer.h | 10 +- .../image_replayer/snapshot/StateBuilder.cc | 5 +- .../image_replayer/snapshot/StateBuilder.h | 1 + 13 files changed, 312 insertions(+), 115 deletions(-) diff --git a/qa/workunits/rbd/rbd_mirror.sh b/qa/workunits/rbd/rbd_mirror.sh index 7f8afddc613..99166f169ba 100755 --- a/qa/workunits/rbd/rbd_mirror.sh +++ b/qa/workunits/rbd/rbd_mirror.sh @@ -274,6 +274,60 @@ wait_for_replaying_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+stopped' compare_images ${CLUSTER1} ${CLUSTER2} ${POOL} ${POOL} ${image} +if [ "${RBD_MIRROR_MODE}" = "snapshot" ]; then + testlog "TEST: failover / failback loop with snapshot presence checks" + snap_id_1="" + demote_snap_id_1="" + snap_id_2="" + demote_snap_id_2="" + get_newest_complete_mirror_snapshot_id ${CLUSTER2} ${POOL} ${image} snap_id_2 + for i in `seq 1 5`; do + demote_image ${CLUSTER2} ${POOL} ${image} + wait_for_image_replay_stopped ${CLUSTER1} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+unknown' + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+unknown' + get_newest_complete_mirror_snapshot_id ${CLUSTER2} ${POOL} ${image} demote_snap_id_2 + wait_for_non_primary_snap_present ${CLUSTER1} ${POOL} ${image} ${demote_snap_id_2} + wait_for_non_primary_snap_not_present ${CLUSTER1} ${POOL} ${image} ${snap_id_2} + + promote_image ${CLUSTER1} ${POOL} ${image} + wait_for_image_replay_started ${CLUSTER2} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+stopped' + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+replaying' + get_newest_complete_mirror_snapshot_id ${CLUSTER1} ${POOL} ${image} snap_id_1 + wait_for_non_primary_snap_present ${CLUSTER2} ${POOL} ${image} ${snap_id_1} + wait_for_snap_not_present ${CLUSTER2} ${POOL} ${image} ${snap_id_2} + wait_for_snap_not_present ${CLUSTER2} ${POOL} ${image} ${demote_snap_id_2} + + demote_image ${CLUSTER1} ${POOL} ${image} + wait_for_non_primary_snap_not_present ${CLUSTER1} ${POOL} ${image} ${demote_snap_id_2} + wait_for_image_replay_stopped ${CLUSTER2} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+unknown' + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+unknown' + get_newest_complete_mirror_snapshot_id ${CLUSTER1} ${POOL} ${image} demote_snap_id_1 + wait_for_non_primary_snap_present ${CLUSTER2} ${POOL} ${image} ${demote_snap_id_1} + wait_for_non_primary_snap_not_present ${CLUSTER2} ${POOL} ${image} ${snap_id_1} + + promote_image ${CLUSTER2} ${POOL} ${image} + wait_for_image_replay_started ${CLUSTER1} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+stopped' + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+replaying' + get_newest_complete_mirror_snapshot_id ${CLUSTER2} ${POOL} ${image} snap_id_2 + wait_for_non_primary_snap_present ${CLUSTER1} ${POOL} ${image} ${snap_id_2} + wait_for_snap_not_present ${CLUSTER1} ${POOL} ${image} ${snap_id_1} + wait_for_snap_not_present ${CLUSTER1} ${POOL} ${image} ${demote_snap_id_1} + done + # expected snapshot in non-primary image: non-primary + SNAPS=$(get_snaps_json ${CLUSTER1} ${POOL} ${image}) + jq -e 'length == 1' <<< ${SNAPS} + jq -e '.[0].namespace["type"] == "mirror" and .[0].namespace["state"] == "non-primary"' <<< ${SNAPS} + # expected snapshots in primary image: non-primary demoted, primary + SNAPS=$(get_snaps_json ${CLUSTER2} ${POOL} ${image}) + jq -e 'length == 2' <<< ${SNAPS} + jq -e '.[0].namespace["type"] == "mirror" and .[0].namespace["state"] == "demoted" and (.[0]["name"] | startswith(".mirror.non_primary"))' <<< ${SNAPS} + jq -e '.[1].namespace["type"] == "mirror" and .[1].namespace["state"] == "primary"' <<< ${SNAPS} +fi + testlog "TEST: failover / failback loop" for i in `seq 1 20`; do demote_image ${CLUSTER2} ${POOL} ${image} diff --git a/qa/workunits/rbd/rbd_mirror_helpers.sh b/qa/workunits/rbd/rbd_mirror_helpers.sh index 324b0877655..50903971643 100755 --- a/qa/workunits/rbd/rbd_mirror_helpers.sh +++ b/qa/workunits/rbd/rbd_mirror_helpers.sh @@ -826,6 +826,43 @@ wait_for_non_primary_snap_present() return 1 } +wait_for_snap_not_present() +{ + local cluster=$1 + local pool=$2 + local image=$3 + local snap_id=$4 + local snap_count + local s + + for s in 0.1 1 2 4 8 8 8 8 8 8 8 8 16 16 32 32; do + sleep ${s} + snap_count=$(rbd --cluster ${cluster} snap list --all ${pool}/${image} --format xml | \ + xmlstarlet sel -t -v "count(//snapshots/snapshot[id='${snap_id}'])") + [ "${snap_count}" = "0" ] && return 0 + done + + return 1 +} + +wait_for_non_primary_snap_not_present() +{ + local cluster=$1 + local pool=$2 + local image=$3 + local primary_snap_id=$4 + local snap_count + local s + + for s in 0.1 1 2 4 8 8 8 8 8 8 8 8 16 16 32 32; do + sleep ${s} + snap_count=$(rbd --cluster ${cluster} snap list --all ${pool}/${image} --format xml | \ + xmlstarlet sel -t -v "count(//snapshots/snapshot/namespace[primary_snap_id='${primary_snap_id}'])") + [ "${snap_count}" = "0" ] && return 0 + done + return 1 +} + wait_for_snapshot_sync_complete() { local local_cluster=$1 diff --git a/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc b/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc index db85304f06b..a2dabf2e0ae 100644 --- a/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc +++ b/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc @@ -495,8 +495,8 @@ public: })); } - void expect_prune_non_primary_snapshot(librbd::MockTestImageCtx& mock_image_ctx, - uint64_t snap_id, int r) { + void expect_prune_mirror_snapshot(librbd::MockTestImageCtx& mock_image_ctx, + uint64_t snap_id, int r) { EXPECT_CALL(mock_image_ctx, get_snap_info(snap_id)) .WillOnce(Invoke([&mock_image_ctx](uint64_t snap_id) -> librbd::SnapInfo* { auto it = mock_image_ctx.snap_info.find(snap_id); @@ -734,8 +734,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InitShutDown) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -788,8 +789,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, SyncSnapshot) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -913,7 +915,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, SyncSnapshot) { 4, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); - expect_prune_non_primary_snapshot(mock_local_image_ctx, 11, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); // idle expect_load_image_meta(mock_image_meta, false, 0); @@ -966,8 +968,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncInitial) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1051,8 +1054,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDelta) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1129,7 +1133,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDelta) { 2, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); - expect_prune_non_primary_snapshot(mock_local_image_ctx, 11, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); // idle expect_load_image_meta(mock_image_meta, false, 0); @@ -1172,8 +1176,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDeltaDemote) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1201,7 +1206,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDeltaDemote) { mock_local_image_ctx.snap_info = { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", @@ -1229,7 +1234,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDeltaDemote) { false, 0); expect_notify_sync_complete(mock_instance_watcher, mock_local_image_ctx.id); - // idle + // prune primary demotion snap1 expect_load_image_meta(mock_image_meta, false, 0); expect_is_refresh_required(mock_remote_image_ctx, true); expect_refresh( @@ -1244,13 +1249,26 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedSyncDeltaDemote) { mock_local_image_ctx, { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", 2, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); + + // idle + expect_load_image_meta(mock_image_meta, false, 0); + expect_is_refresh_required(mock_remote_image_ctx, false); + expect_is_refresh_required(mock_local_image_ctx, true); + expect_refresh( + mock_local_image_ctx, { + {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", + 2, true, 0, {}}, + 0, {}, 0, 0, {}}}, + }, 0); // wake-up replayer update_watch_ctx->handle_notify(); @@ -1281,8 +1299,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncInitial) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1365,8 +1384,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDelta) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1404,7 +1424,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDelta) { expect_load_image_meta(mock_image_meta, false, 0); expect_is_refresh_required(mock_remote_image_ctx, false); expect_is_refresh_required(mock_local_image_ctx, false); - expect_prune_non_primary_snapshot(mock_local_image_ctx, 12, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 12, 0); // sync snap2 expect_load_image_meta(mock_image_meta, false, 0); @@ -1462,7 +1482,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDelta) { 2, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); - expect_prune_non_primary_snapshot(mock_local_image_ctx, 11, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); // idle expect_load_image_meta(mock_image_meta, false, 0); @@ -1505,8 +1525,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1534,7 +1555,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) mock_local_image_ctx.snap_info = { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", @@ -1545,7 +1566,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) expect_load_image_meta(mock_image_meta, false, 0); expect_is_refresh_required(mock_remote_image_ctx, false); expect_is_refresh_required(mock_local_image_ctx, false); - expect_prune_non_primary_snapshot(mock_local_image_ctx, 12, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 12, 0); // sync snap2 expect_load_image_meta(mock_image_meta, false, 0); @@ -1555,7 +1576,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) mock_local_image_ctx, { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); MockSnapshotCopyRequest mock_snapshot_copy_request; @@ -1581,7 +1602,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) false, 0); expect_notify_sync_complete(mock_instance_watcher, mock_local_image_ctx.id); - // idle + // prune primary demotion snap1 expect_load_image_meta(mock_image_meta, false, 0); expect_is_refresh_required(mock_remote_image_ctx, true); expect_refresh( @@ -1596,13 +1617,26 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, InterruptedPendingSyncDeltaDemote) mock_local_image_ctx, { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, {13U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", 2, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); + + // idle + expect_load_image_meta(mock_image_meta, false, 0); + expect_is_refresh_required(mock_remote_image_ctx, false); + expect_is_refresh_required(mock_local_image_ctx, true); + expect_refresh( + mock_local_image_ctx, { + {13U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", + 2, true, 0, {}}, + 0, {}, 0, 0, {}}}, + }, 0); // wake-up replayer update_watch_ctx->handle_notify(); @@ -1633,8 +1667,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteImageDemoted) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1687,8 +1722,8 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteImageDemoted) { expect_refresh( mock_local_image_ctx, { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ - cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, "remote mirror uuid", - 1, true, 0, {}}, + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED, + {"local mirror peer uuid"}, "remote mirror uuid", 1, true, 0, {}}, 0, {}, 0, 0, {}}}, }, 0); @@ -1722,8 +1757,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, LocalImagePromoted) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1740,7 +1776,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, LocalImagePromoted) { mock_local_image_ctx.snap_info = { {1U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY, - {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}}; // idle @@ -1778,8 +1814,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ResyncRequested) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1834,8 +1871,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ResyncRequestedRemoteNotPrimary) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1890,8 +1928,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ResyncRequestedRemoteNotPrimary) { expect_refresh( mock_local_image_ctx, { {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ - cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED, {"uuid"}, - "remote mirror uuid", 1, true, 0, {{1, CEPH_NOSNAP}}}, + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED, + {"local mirror peer uuid"}, "remote mirror uuid", 1, true, 0, + {{1, CEPH_NOSNAP}}}, 0, {}, 0, 0, {}}}, }, 0); @@ -1923,8 +1962,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RegisterLocalUpdateWatcherError) { mock_image_meta); MockReplayerListener mock_replayer_listener; MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1956,8 +1996,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RegisterRemoteUpdateWatcherError) mock_image_meta); MockReplayerListener mock_replayer_listener; MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -1995,8 +2036,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UnregisterRemoteUpdateWatcherError mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2037,8 +2079,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UnregisterLocalUpdateWatcherError) mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2079,8 +2122,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, LoadImageMetaError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2127,8 +2171,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RefreshLocalImageError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2178,8 +2223,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RefreshRemoteImageError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2228,8 +2274,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, CopySnapshotsError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2288,8 +2335,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, GetImageStateError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2350,8 +2398,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, CreateNonPrimarySnapshotError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2416,8 +2465,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UpdateMirrorImageStateError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2484,8 +2534,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RequestSyncError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2554,8 +2605,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, CopyImageError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2627,8 +2679,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UpdateNonPrimarySnapshotError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2704,8 +2757,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UnlinkPeerError) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2792,8 +2846,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, SplitBrain) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2855,8 +2910,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteSnapshotMissingSplitBrain) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2923,8 +2979,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -2956,8 +3013,8 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { {11U, librbd::SnapInfo{"snap1", cls::rbd::UserSnapshotNamespace{}, 0, {}, 0, 0, {}}}, {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ - cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, {}, "", CEPH_NOSNAP, - true, 0, {}}, + cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}}; // attach to promoted remote image @@ -2988,7 +3045,7 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { false, 0); expect_notify_sync_complete(mock_instance_watcher, mock_local_image_ctx.id); - // idle + // prune primary demotion snap2 expect_load_image_meta(mock_image_meta, false, 0); expect_is_refresh_required(mock_remote_image_ctx, true); expect_refresh( @@ -2997,11 +3054,11 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { 0, {}, 0, 0, {}}}, {2U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED, - {"remote mirror peer uuid"}, "local mirror uuid", 12U, true, 0, {}}, + {}, "local mirror uuid", 12U, true, 0, {}}, 0, {}, 0, 0, {}}}, {3U, librbd::SnapInfo{"snap3", cls::rbd::MirrorSnapshotNamespace{ - cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY, {}, "", CEPH_NOSNAP, true, 0, - {}}, + cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY, {"remote mirror peer uuid"}, + "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}} }, 0); expect_is_refresh_required(mock_local_image_ctx, true); @@ -3010,8 +3067,8 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { {11U, librbd::SnapInfo{"snap1", cls::rbd::UserSnapshotNamespace{}, 0, {}, 0, 0, {}}}, {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ - cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, {}, "", CEPH_NOSNAP, - true, 0, {}}, + cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, + {"local mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, 0, {}, 0, 0, {}}}, {13U, librbd::SnapInfo{"snap3", cls::rbd::MirrorSnapshotNamespace{ cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, @@ -3019,6 +3076,22 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, RemoteFailover) { {{1, 11}, {2, 12}, {3, CEPH_NOSNAP}}}, 0, {}, 0, 0, {}}}, }, 0); + expect_prune_mirror_snapshot(mock_local_image_ctx, 12, 0); + + // idle + expect_load_image_meta(mock_image_meta, false, 0); + expect_is_refresh_required(mock_remote_image_ctx, false); + expect_is_refresh_required(mock_local_image_ctx, true); + expect_refresh( + mock_local_image_ctx, { + {11U, librbd::SnapInfo{"snap1", cls::rbd::UserSnapshotNamespace{}, + 0, {}, 0, 0, {}}}, + {13U, librbd::SnapInfo{"snap3", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, {}, + "remote mirror uuid", 3, true, 0, + {{1, 11}, {2, 12}, {3, CEPH_NOSNAP}}}, + 0, {}, 0, 0, {}}}, + }, 0); // wake-up replayer update_watch_ctx->handle_notify(); @@ -3065,8 +3138,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, UnlinkRemoteSnapshot) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -3143,8 +3217,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, SkipImageSync) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -3222,8 +3297,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ImageNameUpdated) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), {"remote mirror uuid", "remote mirror peer uuid"}); @@ -3284,8 +3360,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ApplyImageStatePendingShutdown) { mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; C_SaferCond shutdown_ctx; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), @@ -3371,8 +3448,9 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ApplyImageStateErrorPendingShutdow mock_remote_image_ctx, mock_image_meta); MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, - "local mirror uuid", &m_pool_meta_cache, - &mock_state_builder, &mock_replayer_listener}; + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; C_SaferCond shutdown_ctx; m_pool_meta_cache.set_remote_pool_meta( m_remote_fsid, m_remote_io_ctx.get_id(), diff --git a/src/test/rbd_mirror/test_mock_ImageReplayer.cc b/src/test/rbd_mirror/test_mock_ImageReplayer.cc index 0788ce51bfa..1bdd0c6b96f 100644 --- a/src/test/rbd_mirror/test_mock_ImageReplayer.cc +++ b/src/test/rbd_mirror/test_mock_ImageReplayer.cc @@ -178,9 +178,11 @@ struct StateBuilder { } MOCK_METHOD1(close, void(Context*)); - MOCK_METHOD5(create_replayer, Replayer*(Threads*, + MOCK_METHOD6(create_replayer, Replayer*(Threads*, InstanceWatcher*, - const std::string&, PoolMetaCache*, + const std::string&, + const std::string&, + PoolMetaCache*, ReplayerListener*)); StateBuilder() { @@ -312,8 +314,8 @@ public: void expect_create_replayer(MockStateBuilder& mock_state_builder, MockReplayer& mock_replayer) { - EXPECT_CALL(mock_state_builder, create_replayer(_, _, _, _, _)) - .WillOnce(WithArg<4>( + EXPECT_CALL(mock_state_builder, create_replayer(_, _, _, _, _, _)) + .WillOnce(WithArg<5>( Invoke([&mock_replayer] (image_replayer::ReplayerListener* replayer_listener) { mock_replayer.replayer_listener = replayer_listener; diff --git a/src/tools/rbd_mirror/ImageReplayer.cc b/src/tools/rbd_mirror/ImageReplayer.cc index de4ef76b7c7..9aee9a96b4d 100644 --- a/src/tools/rbd_mirror/ImageReplayer.cc +++ b/src/tools/rbd_mirror/ImageReplayer.cc @@ -431,6 +431,7 @@ void ImageReplayer::start_replay() { ceph_assert(m_replayer == nullptr); m_replayer = m_state_builder->create_replayer(m_threads, m_instance_watcher, m_local_mirror_uuid, + m_remote_image_peer.uuid, m_pool_meta_cache, m_replayer_listener); diff --git a/src/tools/rbd_mirror/Types.h b/src/tools/rbd_mirror/Types.h index f5e9c67fde8..ef13f20fa9c 100644 --- a/src/tools/rbd_mirror/Types.h +++ b/src/tools/rbd_mirror/Types.h @@ -116,7 +116,7 @@ struct Peer { template std::ostream& operator<<(std::ostream& os, const Peer& peer) { - return os << "uuid=" << peer.uuid << ", " + return os << "uuid=" << peer.uuid << ", remote_pool_meta=" << peer.remote_pool_meta; } diff --git a/src/tools/rbd_mirror/image_replayer/StateBuilder.h b/src/tools/rbd_mirror/image_replayer/StateBuilder.h index 0831f72f130..79c27cb19b9 100644 --- a/src/tools/rbd_mirror/image_replayer/StateBuilder.h +++ b/src/tools/rbd_mirror/image_replayer/StateBuilder.h @@ -75,6 +75,7 @@ public: Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, ReplayerListener* replayer_listener) = 0; diff --git a/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.cc b/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.cc index 358af64bea2..9e346e7adc6 100644 --- a/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.cc +++ b/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.cc @@ -106,6 +106,7 @@ image_replayer::Replayer* StateBuilder::create_replayer( Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, ReplayerListener* replayer_listener) { return Replayer::create( diff --git a/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.h b/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.h index 903cce98bfb..5605b1631fa 100644 --- a/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.h +++ b/src/tools/rbd_mirror/image_replayer/journal/StateBuilder.h @@ -65,6 +65,7 @@ public: Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, ReplayerListener* replayer_listener) override; diff --git a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc index 14a2113bea5..b85edfc105d 100644 --- a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc +++ b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc @@ -116,12 +116,14 @@ Replayer::Replayer( Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, StateBuilder* state_builder, ReplayerListener* replayer_listener) : m_threads(threads), m_instance_watcher(instance_watcher), m_local_mirror_uuid(local_mirror_uuid), + m_local_mirror_peer_uuid(local_mirror_peer_uuid), m_pool_meta_cache(pool_meta_cache), m_state_builder(state_builder), m_replayer_listener(replayer_listener), @@ -171,7 +173,9 @@ void Replayer::init(Context* on_finish) { } m_remote_mirror_peer_uuid = remote_pool_meta.mirror_peer_uuid; - dout(10) << "remote_mirror_peer_uuid=" << m_remote_mirror_peer_uuid << dendl; + dout(10) << "local_mirror_peer_uuid=" << m_local_mirror_peer_uuid + << ", remote_mirror_peer_uuid=" << m_remote_mirror_peer_uuid + << dendl; { auto local_image_ctx = m_state_builder->local_image_ctx; @@ -539,6 +543,18 @@ void Replayer::scan_local_mirror_snapshots( } } else if (mirror_ns->is_primary()) { if (mirror_ns->complete) { + const auto& peer_uuids = mirror_ns->mirror_peer_uuids; + if (peer_uuids.empty() || + (mirror_ns->state == cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED && + peer_uuids.size() == 1 && + peer_uuids.count(m_local_mirror_peer_uuid) == 1)) { + // After relocation, the primary snapshots on the new secondary become + // obsolete snapshots. They can be pruned if they are already unlinked. + // Additionally, in this case, a demoted primary snapshot that is linked + // to only a single remote (current primary) peer is safe to prune, + // because no other peer depends on it waiting for sync. + prune_snap_ids.insert(local_snap_id); + } m_local_snap_id_start = local_snap_id; ceph_assert(m_local_snap_id_end == CEPH_NOSNAP); } else { @@ -564,8 +580,8 @@ void Replayer::scan_local_mirror_snapshots( locker->unlock(); auto prune_snap_id = *prune_snap_ids.begin(); - dout(5) << "pruning unused non-primary snapshot " << prune_snap_id << dendl; - prune_non_primary_snapshot(prune_snap_id); + dout(5) << "pruning unused mirror snapshot " << prune_snap_id << dendl; + prune_mirror_snapshot(prune_snap_id); return; } @@ -798,7 +814,7 @@ void Replayer::scan_remote_mirror_snapshots( } template -void Replayer::prune_non_primary_snapshot(uint64_t snap_id) { +void Replayer::prune_mirror_snapshot(uint64_t snap_id) { dout(10) << "snap_id=" << snap_id << dendl; auto local_image_ctx = m_state_builder->local_image_ctx; @@ -825,18 +841,18 @@ void Replayer::prune_non_primary_snapshot(uint64_t snap_id) { } auto ctx = create_context_callback< - Replayer, &Replayer::handle_prune_non_primary_snapshot>(this); + Replayer, &Replayer::handle_prune_mirror_snapshot>(this); local_image_ctx->operations->snap_remove(snap_namespace, snap_name, ctx); } template -void Replayer::handle_prune_non_primary_snapshot(int r) { +void Replayer::handle_prune_mirror_snapshot(int r) { dout(10) << "r=" << r << dendl; if (r < 0 && r != -ENOENT) { - derr << "failed to prune non-primary snapshot: " << cpp_strerror(r) + derr << "failed to prune mirror snapshot: " << cpp_strerror(r) << dendl; - handle_replay_complete(r, "failed to prune non-primary snapshot"); + handle_replay_complete(r, "failed to prune mirror snapshot"); return; } diff --git a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.h b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.h index 399e636a919..6f111b26d76 100644 --- a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.h +++ b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.h @@ -47,17 +47,20 @@ public: Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, StateBuilder* state_builder, ReplayerListener* replayer_listener) { return new Replayer(threads, instance_watcher, local_mirror_uuid, - pool_meta_cache, state_builder, replayer_listener); + local_mirror_peer_uuid, pool_meta_cache, state_builder, + replayer_listener); } Replayer( Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, StateBuilder* state_builder, ReplayerListener* replayer_listener); @@ -202,6 +205,7 @@ private: Threads* m_threads; InstanceWatcher* m_instance_watcher; std::string m_local_mirror_uuid; + std::string m_local_mirror_peer_uuid; PoolMetaCache* m_pool_meta_cache; StateBuilder* m_state_builder; ReplayerListener* m_replayer_listener; @@ -271,8 +275,8 @@ private: void scan_local_mirror_snapshots(std::unique_lock* locker); void scan_remote_mirror_snapshots(std::unique_lock* locker); - void prune_non_primary_snapshot(uint64_t snap_id); - void handle_prune_non_primary_snapshot(int r); + void prune_mirror_snapshot(uint64_t snap_id); + void handle_prune_mirror_snapshot(int r); void copy_snapshots(); void handle_copy_snapshots(int r); diff --git a/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.cc b/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.cc index b3c58be95f6..d678e6ea87c 100644 --- a/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.cc +++ b/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.cc @@ -105,11 +105,12 @@ image_replayer::Replayer* StateBuilder::create_replayer( Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, ReplayerListener* replayer_listener) { return Replayer::create( - threads, instance_watcher, local_mirror_uuid, pool_meta_cache, this, - replayer_listener); + threads, instance_watcher, local_mirror_uuid, local_mirror_peer_uuid, + pool_meta_cache, this, replayer_listener); } } // namespace snapshot diff --git a/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.h b/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.h index baa8dcedc7a..c2669074014 100644 --- a/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.h +++ b/src/tools/rbd_mirror/image_replayer/snapshot/StateBuilder.h @@ -70,6 +70,7 @@ public: Threads* threads, InstanceWatcher* instance_watcher, const std::string& local_mirror_uuid, + const std::string& local_mirror_peer_uuid, PoolMetaCache* pool_meta_cache, ReplayerListener* replayer_listener) override; From 8e7fc84fef10abe1754df7515292b1352e3e7b2b Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 12 Jun 2026 16:06:33 +0800 Subject: [PATCH 276/596] crimson/osd: remove extraneous co_return no need to use co_return at the end of a coroutine which returns future<>. despite that this does not incur performance penalty, because: co_await func() // func returns future<> returns void co_return is called with void operand, this statement is lowered to promise.return_void() and a bare "co_await func()" also calls return_void(). so they are equivalent at runtime. but the extraneous co_return can be dispensed. also take this opportunity to remove the bare co_return at the end of a coroutine. it's unnecessary just like a `return` at the end of a function returning `void`. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index d742eef94be..55e01cabef4 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -344,7 +344,6 @@ seastar::future<> OSD::mkfs( co_await store.umount(); co_await store.stop(); - co_return; } seastar::future<> OSD::_write_superblock( @@ -712,7 +711,7 @@ seastar::future<> OSD::_send_boot() if (ret == 0) { m->metadata["osd_objectstore"] = type; } - co_return co_await monc->send_message(std::move(m)); + co_await monc->send_message(std::move(m)); } seastar::future<> OSD::_add_device_class() @@ -744,8 +743,6 @@ seastar::future<> OSD::_add_device_class() } else { INFO("device_class was set: {}", message); } - - co_return; } seastar::future<> OSD::_add_me_to_crush() From 15be0e2d3b04ce6435e910d6551546e72024252b Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 12 Jun 2026 16:28:57 +0800 Subject: [PATCH 277/596] crimson/osd: avoid unnecessary co_await make_ready_future call no need to create a future then co_await it. let's just co_return it. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 55e01cabef4..63b5d4901cc 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -1723,7 +1723,7 @@ seastar::future OSD::run_bench(int64_t count, int64_t bsize, int64_t osi auto end = std::chrono::steady_clock::now(); double elapsed = std::chrono::duration(end - start).count(); co_await seastar::when_all_succeed(cleanup_futures.begin(), cleanup_futures.end()); - co_return co_await seastar::make_ready_future(elapsed); + co_return elapsed; } seastar::future<> OSD::update_heartbeat_peers() From 17c6e03f17f464b3c94e8e0f45c34e09746cbab7 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 12 Jun 2026 16:36:32 +0800 Subject: [PATCH 278/596] crimson/osd: use std::unique_lock when appropriate instead of lock and unlock a mutex manually, let's use the RAII helper. simpler this way. Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 63b5d4901cc..0030e04a781 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -1187,11 +1187,8 @@ seastar::future<> OSD::handle_osd_map(Ref m) * See https://tracker.ceph.com/issues/59165 */ ceph_assert(seastar::this_shard_id() == PRIMARY_CORE); - return handle_osd_map_lock.lock().then([this, m] { - return _handle_osd_map(m); - }).finally([this] { - return handle_osd_map_lock.unlock(); - }); + const auto lock = co_await seastar::get_unique_lock(handle_osd_map_lock); + co_await _handle_osd_map(m); } seastar::future<> OSD::_handle_osd_map(Ref m) From ded0d4c14649206a58b3ca3236315f9a29cb328e Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 12 Jun 2026 17:30:11 +0800 Subject: [PATCH 279/596] crimson/osd: coroutinize OSD methods this change migrate most of the OSD implementation to C++20 coroutine when appropriate to improve the readability and maintainability. there are two patterns in this migration: - mechanic replacing future-promise then() chain with co_await/co_return - replace `return ` with a `co_return` statement - replace `seastar::do_for_each()` with a regular for loop, and optionally restructure the code so it's more compacted. - replace handle_exception() with regular try-catch block Signed-off-by: Kefu Chai --- src/crimson/osd/osd.cc | 275 ++++++++++++++++------------------------- 1 file changed, 107 insertions(+), 168 deletions(-) diff --git a/src/crimson/osd/osd.cc b/src/crimson/osd/osd.cc index 0030e04a781..25096a1fb8a 100644 --- a/src/crimson/osd/osd.cc +++ b/src/crimson/osd/osd.cc @@ -352,12 +352,8 @@ seastar::future<> OSD::_write_superblock( OSDSuperblock superblock) { LOG_PREFIX(OSD::_write_superblock); - return seastar::do_with( - std::move(meta_coll), - std::move(superblock), - [&store, FNAME](auto &meta_coll, auto &superblock) { - DEBUG("try loading existing superblock"); - return meta_coll.load_superblock( + DEBUG("try loading existing superblock"); + co_await meta_coll.load_superblock( ).safe_then([&superblock, FNAME](OSDSuperblock&& sb) { if (sb.cluster_fsid != superblock.cluster_fsid) { ERROR("provided cluster fsid {} != superblock's {}", @@ -386,7 +382,6 @@ seastar::future<> OSD::_write_superblock( }), crimson::ct_error::assert_all("_write_superblock error") ); - }); } // this `to_string` sits in the `crimson::osd` namespace, so we don't brake @@ -400,19 +395,18 @@ seastar::future<> OSD::_write_key_meta(FuturizedStore &store) { LOG_PREFIX(OSD::_write_key_meta); if (auto key = local_conf().get_val("key"); !std::empty(key)) { - return store.write_meta("osd_key", key); + co_await store.write_meta("osd_key", key); } else if (auto keyfile = local_conf().get_val("keyfile"); !std::empty(keyfile)) { - return read_file(keyfile).then([&store](const auto& temp_buf) { + try { + const auto temp_buf = co_await read_file(keyfile); // it's on a truly cold path, so don't worry about memcpy. - return store.write_meta("osd_key", to_string(temp_buf)); - }).handle_exception([FNAME, keyfile] (auto ep) { + co_await store.write_meta("osd_key", to_string(temp_buf)); + } catch (...) { ERROR("_write_key_meta: failed to handle keyfile {}: {}", - keyfile, ep); + keyfile, std::current_exception()); ceph_abort(); - }); - } else { - return seastar::now(); + }; } } @@ -749,47 +743,33 @@ seastar::future<> OSD::_add_me_to_crush() { LOG_PREFIX(OSD::_add_me_to_crush); if (!local_conf().get_val("osd_crush_update_on_start")) { - return seastar::now(); + co_return; } - auto get_weight = [this] { - if (auto w = local_conf().get_val("osd_crush_initial_weight"); - w >= 0) { - return seastar::make_ready_future(w); - } else { - return store.stat().then([](auto st) { - auto total = st.total; - return seastar::make_ready_future( - std::max(.00001, - double(total) / double(1ull << 40))); // TB - }); - } - }; - return get_weight().then([FNAME, this](auto weight) { - const crimson::crush::CrushLocation loc; - return seastar::do_with( - std::move(loc), - [FNAME, this, weight] (crimson::crush::CrushLocation& loc) { - return loc.init_on_startup().then([FNAME, this, weight, &loc]() { - INFO("crush location is {}", loc); - string cmd = fmt::format(R"({{ + double weight = 0; + if (auto w = local_conf().get_val("osd_crush_initial_weight"); + w >= 0) { + weight = w; + } else { + auto st = co_await store.stat(); + auto total = st.total; + weight = std::max(.00001, + double(total) / double(1ull << 40)); // TB + } + crimson::crush::CrushLocation loc; + co_await loc.init_on_startup(); + INFO("crush location is {}", loc); + string cmd = fmt::format(R"({{ "prefix": "osd crush create-or-move", "id": {}, "weight": {:.4f}, "args": [{}] }})", whoami, weight, loc); - return monc->run_command(std::move(cmd), {}); - }); - }); - }).then([FNAME](auto&& command_result) { - [[maybe_unused]] auto [code, message, out] = std::move(command_result); - if (code) { - WARN("fail to add to crush: {} ({})", message, code); - throw std::runtime_error("fail to add to crush"); - } else { - INFO("added to crush: {}", message); - } - return seastar::now(); - }); + [[maybe_unused]] auto [code, message, out] = co_await monc->run_command(std::move(cmd), {}); + if (code) { + WARN("fail to add to crush: {} ({})", message, code); + throw std::runtime_error("fail to add to crush"); + } + INFO("added to crush: {}", message); } seastar::future<> OSD::handle_command( @@ -862,45 +842,33 @@ seastar::future<> OSD::stop() beacon_timer.cancel(); tick_timer.cancel(); // see also OSD::shutdown() - return prepare_to_stop().then([this] { - return pg_shard_manager.set_stopping(); - }).then([FNAME, this] { + co_await prepare_to_stop(); + co_await pg_shard_manager.set_stopping(); + + try { DEBUG("prepared to stop"); public_msgr->stop(); cluster_msgr->stop(); auto gate_close_fut = gate.close_all(); - return asok->stop().then([this] { - return heartbeat->stop(); - }).then([this] { - return pg_shard_manager.stop_registries(); - }).then([this] { - return store.umount(); - }).then([this] { - return store.stop(); - }).then([this] { - return pg_shard_manager.stop_pgs(); - }).then([this] { - return monc->stop(); - }).then([this] { - return mgrc->stop(); - }).then([this] { - return shard_services.stop(); - }).then([this] { - return osd_states.stop(); - }).then([this] { - return osd_singleton_state.stop(); - }).then([this] { - return pg_to_shard_mappings.stop(); - }).then([fut=std::move(gate_close_fut)]() mutable { - return std::move(fut); - }).then([this] { - return when_all_succeed( - public_msgr->shutdown(), - cluster_msgr->shutdown()).discard_result(); - }).handle_exception([FNAME](auto ep) { - ERROR("error while stopping osd: {}", ep); - }); - }); + co_await asok->stop(); + co_await heartbeat->stop(); + co_await pg_shard_manager.stop_registries(); + co_await store.umount(); + co_await store.stop(); + co_await pg_shard_manager.stop_pgs(); + co_await monc->stop(); + co_await mgrc->stop(); + co_await shard_services.stop(); + co_await osd_states.stop(); + co_await osd_singleton_state.stop(); + co_await pg_to_shard_mappings.stop(); + co_await std::move(gate_close_fut); + co_await when_all_succeed( + public_msgr->shutdown(), + cluster_msgr->shutdown()).discard_result(); + } catch (...) { + ERROR("error while stopping osd: {}", std::current_exception()); + }; } void OSD::dump_status(Formatter* f) const @@ -1127,52 +1095,33 @@ seastar::future OSD::get_stats() { // MPGStats::had_map_for is not used since PGMonitor was removed auto m = crimson::make_message(monc->get_fsid(), osdmap->get_epoch()); - return get_shard_services().get_osd_stat().then([this, m=m.get()](auto&& osd_stat) { - m->osd_stat = std::move(osd_stat); - return pg_shard_manager.get_pg_stats(); - }).then([this, m=std::move(m)](auto &&stats) mutable { - min_last_epoch_clean = osdmap->get_epoch(); - min_last_epoch_clean_pgs.clear(); - std::set pool_set; - for (auto [pgid, stat] : stats) { - min_last_epoch_clean = std::min(min_last_epoch_clean, - stat.get_effective_last_epoch_clean()); - min_last_epoch_clean_pgs.push_back(pgid); - int64_t pool_id = pgid.pool(); - pool_set.emplace(pool_id); + m->osd_stat = co_await get_shard_services().get_osd_stat(); + + auto stats = co_await pg_shard_manager.get_pg_stats(); + min_last_epoch_clean = osdmap->get_epoch(); + min_last_epoch_clean_pgs.clear(); + + std::map pool_stat; + for (auto [pgid, stat] : stats) { + min_last_epoch_clean = std::min(min_last_epoch_clean, + stat.get_effective_last_epoch_clean()); + min_last_epoch_clean_pgs.push_back(pgid); + + if (int64_t pool_id = pgid.pool(); !pool_stat.contains(pool_id)) { + pool_stat[pool_id] = co_await store.pool_statfs(pool_id); } - m->pg_stat = std::move(stats); - return std::make_pair(pool_set, std::move(m)); - }).then([this] (auto message) mutable { - std::map pool_stat; - auto pool_set = message.first; - auto m = std::move(message.second); - return seastar::do_with(std::move(m), - pool_stat, - pool_set, [this] (auto &&msg, - auto &pool_stat, - auto &pool_set) { - return seastar::do_for_each(pool_set, [this, &pool_stat] - (auto& pool_id) { - return store.pool_statfs(pool_id).then([pool_id, &pool_stat]( - store_statfs_t st) mutable { - pool_stat[pool_id] = st; - }); - }).then([&pool_stat, msg=std::move(msg)] () mutable { - msg->pool_stat = std::move(pool_stat); - return seastar::make_ready_future(std::move(msg)); - }); - }); - }); + } + m->pg_stat = std::move(stats); + m->pool_stat = std::move(pool_stat); + co_return std::move(m); } seastar::future OSD::send_pg_stats() { - return get_shard_services().get_osd_stat().then([this](auto&& osd_stat) { - // mgr client sends the report message in background - mgrc->report(); - return osd_stat.seq; - }); + const auto osd_stat = co_await get_shard_services().get_osd_stat(); + // mgr client sends the report message in background + mgrc->report(); + co_return osd_stat.seq; } seastar::future<> OSD::handle_osd_map(Ref m) @@ -1383,8 +1332,7 @@ seastar::future<> OSD::handle_pg_create( Ref m) { LOG_PREFIX(OSD::handle_pg_create); - return seastar::do_for_each(m->pgs, [FNAME, this, conn, m](auto& pg) { - auto& [pgid, when] = pg; + for (auto& [pgid, when] : m->pgs) { const auto &[created, created_stamp] = when; auto q = m->pg_extra.find(pgid); ceph_assert(q != m->pg_extra.end()); @@ -1401,19 +1349,18 @@ seastar::future<> OSD::handle_pg_create( "unmatched past_intervals {} (history {})", pgid, m->epoch, pi, history); - return seastar::now(); - } else { - return pg_shard_manager.start_pg_operation( - conn, - pg_shard_t(), - pgid, - m->epoch, - m->epoch, - NullEvt(), - true, - new PGCreateInfo(pgid, m->epoch, history, pi, true)).second; + continue; } - }); + co_await pg_shard_manager.start_pg_operation( + conn, + pg_shard_t(), + pgid, + m->epoch, + m->epoch, + NullEvt(), + true, + new PGCreateInfo(pgid, m->epoch, history, pi, true)).second; + } } seastar::future<> OSD::handle_update_log_missing( @@ -1468,9 +1415,9 @@ seastar::future<> OSD::handle_scrub_command( LOG_PREFIX(OSD::handle_scrub_command); if (m->fsid != superblock.cluster_fsid) { WARN("fsid mismatched"); - return seastar::now(); + co_return; } - return seastar::parallel_for_each(std::move(m->scrub_pgs), + co_await seastar::parallel_for_each(std::move(m->scrub_pgs), [m, conn, this](spg_t pgid) { return pg_shard_manager.start_pg_operation< crimson::osd::ScrubRequested @@ -1495,7 +1442,7 @@ seastar::future<> OSD::handle_mark_me_down( if (pg_shard_manager.is_prestop()) { got_stop_ack(); } - return seastar::now(); + co_return; } seastar::future<> OSD::handle_recovery_subreq( @@ -1607,7 +1554,7 @@ OSD::handle_some_ec_messages(crimson::net::ConnectionRef conn, MessageRefT&& m) (void) pg_shard_manager.start_pg_operation( std::move(conn), std::forward(m)); - return seastar::now(); + co_return; } @@ -1616,14 +1563,11 @@ seastar::future<> OSD::restart() beacon_timer.cancel(); tick_timer.cancel(); stats_timer.cancel(); - return pg_shard_manager.set_up_epoch( - 0 - ).then([this] { - bind_epoch = osdmap->get_epoch(); - // TODO: promote to shutdown if being marked down for multiple times - // rebind messengers - return start_boot(); - }); + co_await pg_shard_manager.set_up_epoch(0); + bind_epoch = osdmap->get_epoch(); + // TODO: promote to shutdown if being marked down for multiple times + // rebind messengers + co_await start_boot(); } seastar::future<> OSD::shutdown() @@ -1631,14 +1575,14 @@ seastar::future<> OSD::shutdown() LOG_PREFIX(OSD::shutdown); INFO("shutting down per osdmap"); abort_source.request_abort(); - return seastar::now(); + co_return; } seastar::future<> OSD::send_beacon() { LOG_PREFIX(OSD::send_beacon); if (!pg_shard_manager.is_active()) { - return seastar::now(); + co_return; } auto beacon = crimson::make_message(osdmap->get_epoch(), min_last_epoch_clean, @@ -1646,7 +1590,7 @@ seastar::future<> OSD::send_beacon() local_conf()->osd_beacon_report_interval); beacon->pgs = min_last_epoch_clean_pgs; DEBUG("{}", *beacon); - return monc->send_message(std::move(beacon)); + co_await monc->send_message(std::move(beacon)); } seastar::future OSD::run_bench(int64_t count, int64_t bsize, int64_t osize, int64_t onum) { @@ -1726,7 +1670,7 @@ seastar::future OSD::run_bench(int64_t count, int64_t bsize, int64_t osi seastar::future<> OSD::update_heartbeat_peers() { if (!pg_shard_manager.is_active()) { - return seastar::now();; + co_return; } pg_shard_manager.for_each_pgid([this](auto &pgid) { @@ -1743,7 +1687,6 @@ seastar::future<> OSD::update_heartbeat_peers() } }); heartbeat->update_peers(whoami); - return seastar::now(); } seastar::future<> OSD::handle_peering_op( @@ -1792,11 +1735,10 @@ seastar::future<> OSD::check_osdmap_features() to_string(last_require_osd_release), to_string(osdmap->require_osd_release)); last_require_osd_release = osdmap->require_osd_release; - return store.write_meta( + co_await store.write_meta( "require_osd_release", stringify((int)osdmap->require_osd_release)); } - return seastar::now(); } seastar::future<> OSD::prepare_to_stop() @@ -1808,9 +1750,10 @@ seastar::future<> OSD::prepare_to_stop() std::chrono::duration( local_conf().get_val("osd_mon_shutdown_timeout"))); - return seastar::with_timeout( - seastar::timer<>::clock::now() + timeout, - monc->send_message( + try { + co_await seastar::with_timeout( + seastar::timer<>::clock::now() + timeout, + monc->send_message( crimson::make_message( monc->get_fsid(), whoami, @@ -1818,13 +1761,9 @@ seastar::future<> OSD::prepare_to_stop() osdmap->get_epoch(), true)).then([this] { return stop_acked.get_future(); - }) - ).handle_exception_type( - [](seastar::timed_out_error&) { - return seastar::now(); - }); + })); + } catch (seastar::timed_out_error&) {} } - return seastar::now(); } } From b9b725cab162c093109212b50a35b675a3c867a1 Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:19:27 +0100 Subject: [PATCH 280/596] doc: Replace Python 2 package names with Python 3 equivalents librados-intro.rst referenced ``python-rados`` for CentOS/RHEL. rbd-openstack.rst referenced ``python-rbd`` for both apt and yum. Python 2 reached end-of-life in January 2020; these package names install the Python 2 bindings (or fail entirely) on current distros. Replace with the correct Python 3 package names: python3-rados and python3-rbd. Fixes: https://tracker.ceph.com/issues/77200 Signed-off-by: Emmanuel Ameh --- doc/rados/api/librados-intro.rst | 5 ++--- doc/rbd/rbd-openstack.rst | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/rados/api/librados-intro.rst b/doc/rados/api/librados-intro.rst index 4365482c01c..42d2dd62dfe 100644 --- a/doc/rados/api/librados-intro.rst +++ b/doc/rados/api/librados-intro.rst @@ -70,8 +70,7 @@ Getting librados for Python --------------------------- The ``rados`` module provides ``librados`` support to Python -applications. You may install ``python3-rados`` for Debian, Ubuntu, SLE or -openSUSE or the ``python-rados`` package for CentOS/RHEL. +applications. You may install the ``python3-rados`` package. To install ``librados`` development support files for Python on Debian/Ubuntu distributions, execute the following: @@ -85,7 +84,7 @@ distributions, execute the following: .. prompt:: bash $ - sudo yum install python-rados + sudo yum install python3-rados To install ``librados`` development support files for Python on SLE/openSUSE distributions, execute the following: diff --git a/doc/rbd/rbd-openstack.rst b/doc/rbd/rbd-openstack.rst index 7d64b3548b9..e9a62c2a988 100644 --- a/doc/rbd/rbd-openstack.rst +++ b/doc/rbd/rbd-openstack.rst @@ -117,8 +117,8 @@ Install Ceph client packages On the ``glance-api`` node, you will need the Python bindings for ``librbd``:: - sudo apt-get install python-rbd - sudo yum install python-rbd + sudo apt-get install python3-rbd + sudo yum install python3-rbd On the ``nova-compute``, ``cinder-backup`` and on the ``cinder-volume`` node, use both the Python bindings and the client command line tools:: From 3966415dab62d7ce250a8d14fdd762f94f0365bb Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:21:38 +0100 Subject: [PATCH 281/596] doc/install: Update mirrors.rst to use https and current release All mirror URLs used insecure http://. Update to https://. The example repository paths used debian-hammer and rpm-hammer (Hammer was EOL in 2016); update to the current stable release (tentacle). Also update the GitHub mirroring link from the stale master branch to main. Fixes: https://tracker.ceph.com/issues/77197 Signed-off-by: Emmanuel Ameh --- doc/install/mirrors.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/doc/install/mirrors.rst b/doc/install/mirrors.rst index 11c60db8dc6..ef12f3a49bd 100644 --- a/doc/install/mirrors.rst +++ b/doc/install/mirrors.rst @@ -24,17 +24,18 @@ These mirrors are available on the following locations: - **US-West: US West Coast**: http://us-west.ceph.com/ - **CN: China**: http://mirrors.ustc.edu.cn/ceph/ -You can replace all download.ceph.com URLs with any of the mirrors, for example: +You can replace all ``download.ceph.com`` URLs with any of the mirrors, +for example: -- http://download.ceph.com/tarballs/ -- http://download.ceph.com/debian-hammer/ -- http://download.ceph.com/rpm-hammer/ +- https://download.ceph.com/tarballs/ +- https://download.ceph.com/debian-tentacle/ +- https://download.ceph.com/rpm-tentacle/ Change this to: -- http://eu.ceph.com/tarballs/ -- http://eu.ceph.com/debian-hammer/ -- http://eu.ceph.com/rpm-hammer/ +- https://eu.ceph.com/tarballs/ +- https://eu.ceph.com/debian-tentacle/ +- https://eu.ceph.com/rpm-tentacle/ Mirroring @@ -62,4 +63,4 @@ set for all mirrors. These can be found on `GitHub`_. If you want to apply for an official mirror, please contact the ceph-users mailinglist. -.. _GitHub: https://github.com/ceph/ceph/tree/master/mirroring +.. _GitHub: https://github.com/ceph/ceph/tree/main/mirroring From 0531ac89504e07830f372640d28d95dd323b71a6 Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:24:52 +0100 Subject: [PATCH 282/596] doc/install: Update EOL version floor references in index.rst The install overview cited Octopus (EOL 2022) as the cephadm minimum and Nautilus (EOL 2021) as the Rook minimum. Update to Reef and Pacific respectively, which are the currently supported release floor values. Remove the repeated Nautilus/Octopus codename references in the ceph-ansible paragraph, as these are historical release names not needed to convey the message. Fixes: https://tracker.ceph.com/issues/77192 Signed-off-by: Emmanuel Ameh --- doc/install/index.rst | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/doc/install/index.rst b/doc/install/index.rst index ed3dec18888..5be6dda1ffa 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -12,7 +12,6 @@ Recommended methods :ref:`Cephadm ` is a tool that can be used to install and manage a Ceph cluster. -* cephadm supports only Octopus and newer releases. * cephadm is fully integrated with the orchestration API and fully supports the CLI and dashboard features that are used to manage cluster deployment. * cephadm requires container support (in the form of Podman or Docker) and @@ -24,7 +23,6 @@ in Kubernetes, while also enabling management of storage resources and provisioning via Kubernetes APIs. We recommend Rook as the way to run Ceph in Kubernetes or to connect an existing Ceph storage cluster to Kubernetes. -* Rook supports only Nautilus and newer releases of Ceph. * Rook is the preferred method for running Ceph on Kubernetes, or for connecting a Kubernetes cluster to an existing (external) Ceph cluster. @@ -34,15 +32,6 @@ Kubernetes or to connect an existing Ceph storage cluster to Kubernetes. Other methods ~~~~~~~~~~~~~ -`ceph-ansible `_ deploys and manages -Ceph clusters using Ansible. - -* ceph-ansible is widely deployed. -* ceph-ansible is not integrated with the orchestrator APIs that were - introduced in Nautilus and Octopus, which means that the management features - and dashboard integration introduced in Nautilus and Octopus are not - available in Ceph clusters deployed by means of ceph-ansible. - `ceph-salt `_ installs Ceph using Salt and cephadm. `jaas.ai/ceph-mon `_ installs Ceph using Juju. From be52c92ace53387cb578a938c82cee6af4689261 Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:34:31 +0100 Subject: [PATCH 283/596] doc/rados/configuration: Fix FileStore contradiction in journal-ref.rst The page carried a deprecation warning on line 4 stating FileStore "is no longer supported" but line 10 said "Filestore is preferred for new deployments" -- a direct contradiction. Fix by: - Expanding the warning to explicitly state FileStore must not be used for new deployments and to use BlueStore instead - Updating the intro paragraph to reflect that BlueStore is the default and recommended back end, and that this page is for pre-existing Filestore OSDs pending migration only Fixes: https://tracker.ceph.com/issues/77183 Signed-off-by: Emmanuel Ameh --- doc/rados/configuration/journal-ref.rst | 31 ++++--------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/doc/rados/configuration/journal-ref.rst b/doc/rados/configuration/journal-ref.rst index ea87f9ac9c3..7bb72238750 100644 --- a/doc/rados/configuration/journal-ref.rst +++ b/doc/rados/configuration/journal-ref.rst @@ -1,34 +1,13 @@ ========================== Journal Config Reference ========================== -.. warning:: Filestore has been deprecated in the Reef release and is no longer supported. +.. warning:: Filestore is not supported. Migrate existing Filestore OSDs to + BlueStore. See :ref:`rados_operations_bluestore_migration`. + .. index:: journal; journal configuration -Filestore OSDs use a journal for two reasons: speed and consistency. Note -that since Luminous, the BlueStore OSD back end has been preferred and default. -This information is provided for pre-existing OSDs and for rare situations where -Filestore is preferred for new deployments. - -- **Speed:** The journal enables the Ceph OSD Daemon to commit small writes - quickly. Ceph writes small, random I/O to the journal sequentially, which - tends to speed up bursty workloads by allowing the backing file system more - time to coalesce writes. The Ceph OSD Daemon's journal, however, can lead - to spiky performance with short spurts of high-speed writes followed by - periods without any write progress as the file system catches up to the - journal. - -- **Consistency:** Ceph OSD Daemons require a file system interface that - guarantees atomic compound operations. Ceph OSD Daemons write a description - of the operation to the journal and apply the operation to the file system. - This enables atomic updates to an object (for example, placement group - metadata). Every few seconds--between ``filestore max sync interval`` and - ``filestore min sync interval``--the Ceph OSD Daemon stops writes and - synchronizes the journal with the file system, allowing Ceph OSD Daemons to - trim operations from the journal and reuse the space. On failure, Ceph - OSD Daemons replay the journal starting after the last synchronization - operation. - -Ceph OSD Daemons recognize the following journal settings: +The following journal settings are provided for reference to assist with +migration of existing Filestore OSDs to BlueStore. .. confval:: journal_dio .. confval:: journal_aio From a3a347e6d1352dd286fdd3dba416d7971c64753b Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 9 Jun 2026 13:35:34 +0100 Subject: [PATCH 284/596] doc/rados/operations: Strengthen cache-tiering deprecation notice The existing warning hedged with "this does not mean it will certainly be removed", softening the message for a deprecated feature. Operators scanning the page may begin a how-to setup without reading past the introductory prose. - Remove the hedging language; state plainly that cache tiering may be removed without further notice - Add a bold "Do not deploy new cache tiers" directive - Add a note immediately before the how-to content scoping the page to existing deployments only, with a pointer to preferred alternatives Fixes: https://tracker.ceph.com/issues/77184 Signed-off-by: Emmanuel Ameh --- doc/rados/operations/cache-tiering.rst | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/doc/rados/operations/cache-tiering.rst b/doc/rados/operations/cache-tiering.rst index f9edf936fb8..a1f8e49a933 100644 --- a/doc/rados/operations/cache-tiering.rst +++ b/doc/rados/operations/cache-tiering.rst @@ -2,14 +2,16 @@ Cache Tiering =============== -.. warning:: Cache tiering has been deprecated in the Reef release. Cache - tiering has lacked a maintainer for a long time. This does not mean that - it will certainly be removed, but it might be removed without much - notice. +.. warning:: Cache tiering was deprecated in the Reef release and has lacked + a maintainer for a long time. It will be removed in a future release without + notice. **Do not deploy new cache tiers.** Migrate existing cache + tier deployments as soon as possible. - The upstream Ceph community strongly advises against deploying new cache - tiers. The upstream Ceph community also recommends migrating from legacy - deployments. +.. note:: The following documentation is retained for reference for existing + deployments only. For new deployments, use erasure-coded pools or fast + storage pools directly. As a community-supported block-level caching + alternative, consider ``dm-cache`` (the Linux kernel's device-mapper cache + target). A cache tier provides Ceph clients with better I/O performance for a subset of the data stored in a backing storage tier. Cache tiering involves creating a From d814088ce7d0297e134bb0f3894a11c53d7a554a Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sat, 13 Jun 2026 10:23:39 +0800 Subject: [PATCH 285/596] python-common/cryptotools: reimplement on top of cryptography the last change dropped X509Req, but InternalCryptoCaller still uses pyOpenSSL, which keeps deprecating and removing its OpenSSL.crypto and OpenSSL.SSL APIs. pyOpenSSL's own README [1] says: If you are using pyOpenSSL for anything other than making a TLS connection you should move to cryptography and drop your pyOpenSSL dependency. none of this module makes a TLS connection, and we already depend on cryptography and use it in cephadm/ssl_cert_utils.py. so reimplement the caller with cryptography: - create_private_key(): rsa.generate_private_key() dumped as PKCS8 PEM, the same format pyOpenSSL produced. - create_self_signed_cert(): x509.CertificateBuilder, with the dname fields mapped to their NameOIDs. - _load_cert() and get_cert_issuer_info(): load_pem_x509_certificate() and cert.issuer. - certificate_days_to_expire(): cert.not_valid_after. - verify_tls(): compare the cert's and the key's public keys instead of loading them into an SSL.Context. password_hash() and verify_password() already used bcrypt, so they are left as is. the output formats and error behavior are unchanged, hence the existing tls tests pass without changes. [1] https://github.com/pyca/pyopenssl/blob/main/README.rst Signed-off-by: Kefu Chai --- src/mypy.ini | 3 + .../ceph/cryptotools/internal.py | 161 ++++++++++-------- 2 files changed, 96 insertions(+), 68 deletions(-) diff --git a/src/mypy.ini b/src/mypy.ini index 9fe2f82a535..0be1f430484 100755 --- a/src/mypy.ini +++ b/src/mypy.ini @@ -34,6 +34,9 @@ ignore_errors = True [mypy-OpenSSL] ignore_missing_imports = True +[mypy-cryptography,cryptography.*] +ignore_missing_imports = True + [mypy-prettytable] ignore_missing_imports = True diff --git a/src/python-common/ceph/cryptotools/internal.py b/src/python-common/ceph/cryptotools/internal.py index db3d6a5c048..b5410f1177e 100644 --- a/src/python-common/ceph/cryptotools/internal.py +++ b/src/python-common/ceph/cryptotools/internal.py @@ -1,25 +1,43 @@ """Internal execution of cryptographic functions for the ceph mgr """ -from typing import Dict, Any, Tuple, Union +from typing import Any, Dict, NoReturn, Tuple, Union from uuid import uuid4 import datetime import warnings -from OpenSSL import crypto, SSL import bcrypt +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509.oid import NameOID from .caller import CryptoCaller, CryptoCallError +# the relative distinguished names accepted by create_self_signed_cert(), +# mapped to their x509 object identifiers. mgr_util validates the dname +# fields against the same set before we get here. +_RDN_OIDS = { + 'C': NameOID.COUNTRY_NAME, + 'ST': NameOID.STATE_OR_PROVINCE_NAME, + 'L': NameOID.LOCALITY_NAME, + 'O': NameOID.ORGANIZATION_NAME, + 'OU': NameOID.ORGANIZATIONAL_UNIT_NAME, + 'CN': NameOID.COMMON_NAME, + 'emailAddress': NameOID.EMAIL_ADDRESS, +} + + class InternalError(CryptoCallError): pass class InternalCryptoCaller(CryptoCaller): - def fail(self, msg: str) -> None: + def fail(self, msg: str) -> NoReturn: raise InternalError(msg) def password_hash(self, password: str, salt_password: str) -> str: @@ -36,93 +54,100 @@ class InternalCryptoCaller(CryptoCaller): return ok def create_private_key(self) -> str: - pkey = crypto.PKey() - pkey.generate_key(crypto.TYPE_RSA, 2048) - return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode() + pkey = rsa.generate_private_key(public_exponent=65537, key_size=2048) + # PKCS8 PEM, the format pyOpenSSL's dump_privatekey() produced + return pkey.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() def create_self_signed_cert( self, dname: Dict[str, str], pkey: str ) -> str: - _pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey) + _pkey: Any = load_pem_private_key(pkey.encode(), password=None) - # create a self-signed cert and populate its subject with the dname - # settings - cert = crypto.X509() - subj = cert.get_subject() + # build the subject, which is also the issuer as the cert is + # self-signed, from the dname settings + attrs = [] for k, v in dname.items(): - setattr(subj, k, v) - cert.set_subject(subj) - cert.set_serial_number(int(uuid4())) - cert.gmtime_adj_notBefore(0) - cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) # 10 years - cert.set_issuer(cert.get_subject()) - cert.set_pubkey(_pkey) - cert.sign(_pkey, 'sha512') - return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode() + oid = _RDN_OIDS.get(k) + if oid is None: + self.fail('unsupported certificate subject field: %s' % k) + attrs.append(x509.NameAttribute(oid, v)) + name = x509.Name(attrs) - def _load_cert(self, crt: Union[str, bytes]) -> Any: + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(_pkey.public_key()) + .serial_number(int(uuid4())) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=10 * 365)) + .sign(_pkey, hashes.SHA512()) + ) + return cert.public_bytes(serialization.Encoding.PEM).decode() + + def _load_cert(self, crt: Union[str, bytes]) -> x509.Certificate: crt_buffer = crt.encode() if isinstance(crt, str) else crt try: - cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) - except (ValueError, crypto.Error) as e: + cert = x509.load_pem_x509_certificate(crt_buffer) + except ValueError as e: self.fail('Invalid certificate: %s' % str(e)) return cert - def _issuer_info(self, cert: Any) -> Tuple[str, str]: - components = cert.get_issuer().get_components() - org_name = cn = '' - for c in components: - if c[0].decode() == 'O': # org comp - org_name = c[1].decode() - elif c[0].decode() == 'CN': # common name comp - cn = c[1].decode() + def _name_value(self, name: x509.Name, oid: x509.ObjectIdentifier) -> str: + attrs = name.get_attributes_for_oid(oid) + if not attrs: + return '' + # cryptography types NameAttribute.value as str in some releases and + # str | bytes in others; keep it Any so the bytes path type-checks + # either way (like the other cryptography objects in this file). + value: Any = attrs[0].value + return value if isinstance(value, str) else value.decode('utf-8') + + def _issuer_info(self, cert: x509.Certificate) -> Tuple[str, str]: + org_name = self._name_value(cert.issuer, NameOID.ORGANIZATION_NAME) + cn = self._name_value(cert.issuer, NameOID.COMMON_NAME) return (org_name, cn) def certificate_days_to_expire(self, crt: str) -> int: - x509 = self._load_cert(crt) - no_after = x509.get_notAfter() - if not no_after: - self.fail("Certificate does not have an expiration date.") - - end_date = datetime.datetime.strptime( - no_after.decode(), '%Y%m%d%H%M%SZ' - ) - - if x509.has_expired(): - org, cn = self._issuer_info(x509) - msg = 'Certificate issued by "%s/%s" expired on %s' % ( - org, - cn, - end_date, - ) - self.fail(msg) - - # Certificate still valid, calculate and return days until expiration + cert = self._load_cert(crt) + # not_valid_after is naive UTC; not_valid_after_utc only exists since + # cryptography 42, so stick with the portable accessor and silence + # its deprecation warning, same as for utcnow(). with warnings.catch_warnings(): - warnings.simplefilter("ignore") - days_until_exp = (end_date - datetime.datetime.utcnow()).days - return int(days_until_exp) + warnings.simplefilter('ignore') + not_after = cert.not_valid_after + now = datetime.datetime.utcnow() + + if now > not_after: + org, cn = self._issuer_info(cert) + self.fail( + 'Certificate issued by "%s/%s" expired on %s' + % (org, cn, not_after) + ) + + return int((not_after - now).days) def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]: return self._issuer_info(self._load_cert(crt)) def verify_tls(self, crt: str, key: str) -> None: try: - _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) - _key.check() - except (ValueError, crypto.Error) as e: + _key: Any = load_pem_private_key(key.encode(), password=None) + except (ValueError, TypeError) as e: self.fail('Invalid private key: %s' % str(e)) - _crt = self._load_cert(crt) - try: - context = SSL.Context(SSL.TLSv1_METHOD) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - context.use_certificate(_crt) - context.use_privatekey(_key) - context.check_privatekey() - except crypto.Error as e: + _crt: Any = self._load_cert(crt) + # the cert and key match iff they share the same public key + pem = serialization.Encoding.PEM + spki = serialization.PublicFormat.SubjectPublicKeyInfo + key_pub = _key.public_key().public_bytes(pem, spki) + crt_pub = _crt.public_key().public_bytes(pem, spki) + if key_pub != crt_pub: self.fail( - 'Private key and certificate do not match up: %s' % str(e) + 'Invalid cert/key pair: ' + 'private key and certificate do not match up' ) - except SSL.Error as e: - self.fail(f'Invalid cert/key pair: {e}') From e5c20fa6a8c8ec030253428c77a8142e22d915ec Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Sun, 14 Jun 2026 09:19:20 +0000 Subject: [PATCH 286/596] crimson,crimson/test: clean-up 'unused' warnings Signed-off-by: Ronen Friedman --- src/crimson/net/io_handler.cc | 1 + src/crimson/os/seastore/object_data_handler.cc | 1 + src/crimson/os/seastore/transaction_manager.cc | 2 +- src/crimson/osd/ec_backend.cc | 1 + src/test/crimson/seastore/test_cbjournal.cc | 4 ++-- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/crimson/net/io_handler.cc b/src/crimson/net/io_handler.cc index c08bdc87422..f9e9198c748 100644 --- a/src/crimson/net/io_handler.cc +++ b/src/crimson/net/io_handler.cc @@ -1266,6 +1266,7 @@ IOHandler::close_io( } else { return shard_states->close( ).then([this] { + std::ignore = this; // as we are 'assert'ing, not ceph_assert'ing assert(shard_states->assert_closed_and_exit()); }); } diff --git a/src/crimson/os/seastore/object_data_handler.cc b/src/crimson/os/seastore/object_data_handler.cc index a586a606d68..65e6b9cf47c 100644 --- a/src/crimson/os/seastore/object_data_handler.cc +++ b/src/crimson/os/seastore/object_data_handler.cc @@ -1453,6 +1453,7 @@ ObjectDataHandler::clear_ret ObjectDataHandler::trim_data_reservation( ctx.t, unaligned_begin.get_aligned_laddr(ctx.tm.get_block_size()) ).si_then([ctx, data_base, size, this, unaligned_begin, &object_data](auto mapping) { + std::ignore = unaligned_begin; assert(mapping.get_key() <= unaligned_begin && mapping.get_key() + mapping.get_length() > unaligned_begin); auto data_len = object_data.get_reserved_data_len(); diff --git a/src/crimson/os/seastore/transaction_manager.cc b/src/crimson/os/seastore/transaction_manager.cc index f0e586220b9..62d24945517 100644 --- a/src/crimson/os/seastore/transaction_manager.cc +++ b/src/crimson/os/seastore/transaction_manager.cc @@ -945,7 +945,7 @@ TransactionManager::move_region( ).handle_error_interruptible( move_region_iertr::pass_further(), crimson::ct_error::assert_all("invalid error")); - auto off = 0; + [[maybe_unused]] auto off = 0; auto bl = maybe_indirect_extent.get_range( src.get_intermediate_offset(), src.get_length()); diff --git a/src/crimson/osd/ec_backend.cc b/src/crimson/osd/ec_backend.cc index 368d29c7147..4ce37c77920 100644 --- a/src/crimson/osd/ec_backend.cc +++ b/src/crimson/osd/ec_backend.cc @@ -457,6 +457,7 @@ ECBackend::handle_rep_write_op( return handle_sub_write( m->op.from, std::move(m->op), pg ).si_then([&pg] { + std::ignore = pg; assert(!pg.pgb_is_primary()); return write_iertr::now(); }, crimson::ct_error::assert_all("unexpected error")); diff --git a/src/test/crimson/seastore/test_cbjournal.cc b/src/test/crimson/seastore/test_cbjournal.cc index ffd9c96c938..c97b771cf3b 100644 --- a/src/test/crimson/seastore/test_cbjournal.cc +++ b/src/test/crimson/seastore/test_cbjournal.cc @@ -249,7 +249,7 @@ struct cbjournal_test_t : public seastar_test_suite_t, JournalTrimmer auto &dirty_seq, auto &alloc_seq, auto last_modified) { - bool found = false; + [[maybe_unused]] bool found = false; for (auto &i : entries) { paddr_t base = offsets.write_result.start_seq.offset; rbm_abs_addr addr = convert_paddr_to_abs_addr(base); @@ -583,7 +583,7 @@ TEST_F(cbjournal_test_t, multiple_submit_at_end) } }); }).get(); - auto old_written_to = get_written_to(); + [[maybe_unused]] auto old_written_to = get_written_to(); cbj->close().unsafe_get(); cbj->replay( [](const auto &offsets, From a803d372ade421a583be2f5c39f0500ab7a0d1e1 Mon Sep 17 00:00:00 2001 From: David Galloway Date: Wed, 17 Jun 2026 08:53:12 -0400 Subject: [PATCH 287/596] Containerfile: Support pulp repo URLs Signed-off-by: David Galloway --- container/Containerfile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/container/Containerfile b/container/Containerfile index d277de30dad..9cc72d164b9 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -124,7 +124,16 @@ RUN --mount=type=secret,id=prerelease_creds set -ex && \ source /run/secrets/prerelease_creds; \ REPO_URL="https://${PRERELEASE_USERNAME}:${PRERELEASE_PASSWORD}@download.ceph.com/prerelease/ceph/rpm-${CEPH_REF}/${EL_VER}/" ;\ fi && \ - rpm -Uvh "$REPO_URL/noarch/ceph-release-1-${IS_RELEASE}.${EL_VER}.noarch.rpm" ; \ + # Trim trailing slashes + REPO_URL="${REPO_URL%/}" && \ + if [[ "$REPO_URL" == */pulp/content/* ]] ; then \ + # Pulp nests packages under noarch/Packages// + CEPH_RELEASE_RPM="$REPO_URL/noarch/Packages/c/ceph-release-1-${IS_RELEASE}.${EL_VER}.noarch.rpm" ; \ + else \ + # chacra serves packages flat under noarch/ + CEPH_RELEASE_RPM="$REPO_URL/noarch/ceph-release-1-${IS_RELEASE}.${EL_VER}.noarch.rpm" ; \ + fi && \ + rpm -Uvh "$CEPH_RELEASE_RPM" ; \ if [[ "$IS_RELEASE" == 1 ]] ; then \ sed -i "s;http://download.ceph.com/;https://${PRERELEASE_USERNAME}:${PRERELEASE_PASSWORD}@download.ceph.com/prerelease/ceph/;" /etc/yum.repos.d/ceph.repo ; \ dnf clean expire-cache ; \ From 3a9ae41e2a8fd614d67e3dac39d28ddf5dd6ca4a Mon Sep 17 00:00:00 2001 From: "Matthew N. Heler" Date: Sun, 17 May 2026 20:57:01 -0500 Subject: [PATCH 288/596] mon: add monitor RocksDB backup and restore Implements an opt-in backup mechanism for the monitor using rocksdb::BackupEngine. Backups run on a schedule when mon_backup_interval is set, or are triggered manually via `ceph tell mon.* backup`. Cleanup keeps the last N, hourly, and daily snapshots, with a free-space guard. Off by default. Restore is offline: stop the mon and run ceph-mon --restore-backup --yes-i-really-mean-it optionally with --backup-version (BackupEngine logical version, as shown by --list-backups). The mon keyring is stashed alongside the RocksDB backup so a wiped mon_data is recovered end-to-end, and kv_backend is stamped back when missing. Co-authored-by: Daniel Poelzleithner Signed-off-by: Matthew N. Heler --- doc/rados/configuration/mon-config-ref.rst | 355 ++++++++++++++++++ .../troubleshooting/troubleshooting-mon.rst | 91 +++++ src/ceph_mon.cc | 75 +++- src/common/options/mon.yaml.in | 68 ++++ src/kv/KeyValueDB.cc | 50 +++ src/kv/KeyValueDB.h | 47 ++- src/kv/RocksDBStore.cc | 283 +++++++++++++- src/kv/RocksDBStore.h | 21 ++ src/mon/CMakeLists.txt | 1 + src/mon/Monitor.cc | 93 ++++- src/mon/Monitor.h | 26 ++ src/mon/MonitorBackup.cc | 214 +++++++++++ src/mon/MonitorBackup.h | 101 +++++ src/mon/MonitorDBStore.h | 233 +++++++++++- src/vstart.sh | 1 + 15 files changed, 1641 insertions(+), 18 deletions(-) create mode 100644 src/mon/MonitorBackup.cc create mode 100644 src/mon/MonitorBackup.h diff --git a/doc/rados/configuration/mon-config-ref.rst b/doc/rados/configuration/mon-config-ref.rst index 18006d0611b..85c003f16b5 100644 --- a/doc/rados/configuration/mon-config-ref.rst +++ b/doc/rados/configuration/mon-config-ref.rst @@ -600,6 +600,361 @@ is far outweighed by the number of accidental pool (and thus data) deletions it For more information about the pool flags see :ref:`Pool values `. +Monitor backup +============== + +In normal operation, Monitor backups are not required: surviving members +of the Monitor quorum re-sync new or replaced Monitors automatically, +and the Monitor store can be largely rebuilt from the OSDs after total +quorum loss (see :ref:`mon-store-recovery-using-osds`). + +However, ``mon-store-recovery-using-osds`` only recovers state that the +OSDs can report: osdmap history, auth keys associated with running +OSDs, and similar. Some Monitor state has no copy outside the Monitor +store and is unrecoverable if all Monitors are lost: + +* **Encryption keys for dm-crypt OSDs** are stored only in the + Monitor's ``config-key`` store under + ``dm-crypt/osd//luks``. Without these keys the underlying + block devices cannot be unlocked, even when the OSD daemons and data + are physically intact. +* **Cephadm orchestrator state** under ``mgr/cephadm/*`` in the + ``config-key`` store, including host inventory, daemon placement + specs, and service definitions. +* **Config-key entries** populated by users or third-party tooling via + ``ceph config-key set``. +* **Dashboard and manager module state** persisted to the + ``config-key`` store. + +A Monitor backup lets an operator restore this state if all running +Monitors are lost. It is most valuable for clusters that use +dm-crypt-encrypted OSDs or that depend heavily on cephadm-managed +deployment state, where loss of the Monitor store would be a +protracted data-availability incident rather than a recoverable inconvenience. + +Monitor backups complement, but do not replace, the existing Monitor +recovery procedures. They are not a means of "undoing" cluster-level +operations such as pool deletion or CRUSH changes: once OSDs have +observed and acted on a newer osdmap, restoring an older Monitor store +does not roll back the OSD-side effects. + +The Ceph Monitor uses the native RocksDB ``BackupEngine`` to create +consistent snapshots of its store, which can be copied elsewhere +without downtime. + +When :confval:`mon_backup_interval` is set, a backup is triggered every +N seconds. Pair it with :confval:`mon_backup_cleanup_interval`; if only +the backup interval is set, backups accumulate indefinitely because +retention is only applied during cleanup. + +Backups share table files (``.sst``) within the backup directory: each +new backup only copies SSTables that the running database has produced +since the previous backup. Restoring any individual backup version is +independent of the others, but the on-disk files for every version +live in a single shared tree under ``mon_backup_path``. + +Layout of the backup directory +------------------------------ + +A backup path managed by the RocksDB ``BackupEngine`` contains three +top-level directories plus a per-version copy of the Monitor keyring:: + + /path/to/backups/ + ├── meta/ + │ ├── 1 + │ ├── 2 + │ └── 3 + ├── private/ + │ ├── 1/ + │ ├── 2/ + │ └── 3/ + ├── shared_checksum/ + │ ├── 000007.sst + │ ├── 000010.sst + │ └── ... + ├── keyring.1 + ├── keyring.2 + └── keyring.3 + +* ``meta/`` is a metadata file describing logical backup version + ``N``. +* ``private//`` contains files unique to backup ``N`` (RocksDB + descriptors and other per-version state). +* ``shared_checksum/`` contains SSTables shared across backup + versions. A single SSTable in this directory may belong to several + versions; the BackupEngine deletes a file from here only when no + remaining backup references it. +* ``keyring.`` is a copy of ``$mon_data/keyring`` taken at the time + of backup ``N``. The Monitor needs this key to authenticate at + startup, and it is not stored inside the RocksDB database; restoring + version ``N`` copies the matching ``keyring.`` back into + ``$mon_data`` so an older snapshot is paired with the keyring of its + vintage. Cleanup removes ``keyring.`` files whose backup version + has been pruned. + + Stashing the keyring is **best effort**: if the copy fails (for + example, permission denied on the backup directory or out of space + after the RocksDB snapshot completed), the backup is still recorded + as successful and the RocksDB data remains usable. On restore, a + missing ``keyring.`` is silently skipped, and the operator must + supply the Monitor keyring out-of-band before starting the daemon. + +.. warning:: + + The backup directory contains the ``[mon.]`` private key. Treat + it with the same access controls as ``$mon_data`` itself; a + complete backup is sufficient material to impersonate a Monitor + in the cluster. + +Do not delete a ``private//`` directory or files under +``shared_checksum/`` by hand: removing a referenced shared file +corrupts every backup that points to it. Use ``backup_cleanup`` or +the configured retention parameters to remove old versions so the +BackupEngine can release shared files safely. + +If the Monitor cluster fails and you need to copy a backup elsewhere, +copy the entire ``/path/to/backups/`` directory. Copying only +``private//`` is not sufficient; the version's SSTables live in +``shared_checksum/``. + + +The :confval:`mon_backup_cleanup_interval` specifies the interval for +backup cleanup. The cleanup algorithm keeps the last +``mon_backup_keep_last`` backups. It then collects hourly +``mon_backup_keep_hourly`` and daily ``mon_backup_keep_daily`` +versions, retaining the newest backup in each time window. + +You can trigger ``backup`` and ``backup_cleanup`` through any running +Monitor's admin socket. + +.. prompt:: bash # + + ceph --admin-daemon .../mon.asok backup + +.. prompt:: bash # + + ceph --admin-daemon .../mon.asok backup_cleanup + +The following metrics related to the monitor backup process are +tracked by ``ceph-mon``. + +.. list-table:: Ceph Monitor Backup Metrics + :widths: 30 12 58 + :header-rows: 1 + + * - Name + - Type + - Description + * - ``backup_running`` + - Gauge + - ``1`` while a backup is in progress, ``0`` otherwise + * - ``backup_started`` + - Counter + - Backup attempts (includes attempts rejected at the :confval:`mon_backup_min_avail` pre-flight check) + * - ``backup_success`` + - Counter + - Backups completed by the ``BackupEngine`` + * - ``backup_failed`` + - Counter + - Failed backup attempts (pre-flight or ``BackupEngine``) + * - ``backup_duration`` + - Average + - Backup wall-clock time + * - ``backup_last_success`` + - Gauge + - UTC timestamp of the most recent successful backup, or ``0`` if none + * - ``backup_last_success_id`` + - Gauge + - ``BackupEngine`` version ID of the most recent successful backup (the value passed to ``--restore-backup --backup-version``) + * - ``backup_last_failed`` + - Gauge + - UTC timestamp of the most recent failed attempt, or ``0`` if none + * - ``backup_last_size`` + - Gauge + - Payload size in bytes of the most recent attempt (may be partial on failure) + * - ``backup_last_files`` + - Gauge + - File count of the most recent attempt (may be partial on failure) + * - ``backup_cleanup_started`` + - Counter + - Cleanup invocations + * - ``backup_cleanup_running`` + - Gauge + - ``1`` while a cleanup is in progress, ``0`` otherwise + * - ``backup_cleanup_success`` + - Counter + - Cleanup passes completed without error + * - ``backup_cleanup_failed`` + - Counter + - Failed cleanup passes + * - ``backup_cleanup_duration`` + - Average + - Cleanup wall-clock time + * - ``backup_cleanup_kept`` + - Gauge + - Backups retained by the most recent cleanup pass + * - ``backup_cleanup_deleted`` + - Gauge + - Backups removed by the most recent cleanup pass + * - ``backup_cleanup_freed`` + - Gauge + - Bytes released by the most recent cleanup pass (overstated when shared backups are in use, because the ``BackupEngine`` payload sum ignores file sharing) + * - ``backup_cleanup_size`` + - Gauge + - Bytes retained by the most recent cleanup pass (same sharing caveat as ``backup_cleanup_freed``) + +The ``backup_started``/``backup_success``/``backup_failed`` and +``backup_cleanup_started``/``backup_cleanup_success``/``backup_cleanup_failed`` +counters are monotonic and accumulate for the lifetime of the +``ceph-mon`` process. The ``backup_last_*`` fields and the four +cleanup result gauges (``backup_cleanup_kept``, ``backup_cleanup_deleted``, +``backup_cleanup_freed``, ``backup_cleanup_size``) describe only the +most recent invocation and are overwritten on every pass. + +To retrieve backup metrics from a running monitor's admin socket: + +.. prompt:: bash # + + ceph --admin-daemon .../mon.asok perf dump | jq '.["mon"] | with_entries(select(.key | startswith("backup_")))' + +.. code-block:: json + + { + "backup_running": 0, + "backup_started": 2, + "backup_success": 2, + "backup_failed": 0, + "backup_duration": { + "avgcount": 2, + "sum": 0.149076498, + "avgtime": 0.074538249 + }, + "backup_last_success": 1722001989.849262, + "backup_last_success_id": 3, + "backup_last_failed": 0, + "backup_last_size": 3924677, + "backup_last_files": 6, + "backup_cleanup_started": 1, + "backup_cleanup_running": 0, + "backup_cleanup_success": 1, + "backup_cleanup_failed": 0, + "backup_cleanup_size": 86144, + "backup_cleanup_kept": 1, + "backup_cleanup_duration": { + "avgcount": 1, + "sum": 0.002031246, + "avgtime": 0.002031246 + }, + "backup_cleanup_freed": 0, + "backup_cleanup_deleted": 0 + } + +Monitor Backup Metric Usage Examples +------------------------------------ + +The following examples show how to use the monitor backup performance +counters. PromQL examples assume that the ``ceph-exporter`` is being +scraped by Prometheus. Admin-socket examples use ``ceph daemon`` +directly against a specific ``ceph-mon`` daemon. + +``backup_last_success`` (gauge, Unix epoch seconds) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The timestamp when the most recent successful backup completed. ``0`` +means no successful backup has occurred since the mon started. + +* Age of the most recent successful backup, per mon: + + .. code-block:: promql + + time() - mon_backup_last_success + +* Detect a stalled backup schedule (no success in over 2 hours; adjust + the threshold to roughly 2× your :confval:`mon_backup_interval`): + + .. code-block:: promql + + time() - mon_backup_last_success > 7200 and mon_backup_last_success > 0 + +``backup_failed`` (counter) +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Cumulative count of failed backup attempts (pre-flight or +``BackupEngine``) since the mon started. + +* Backup failures per mon in the last hour: + + .. code-block:: promql + + increase(mon_backup_failed[1h]) + +* Alert when any mon has logged a backup failure recently: + + .. code-block:: promql + + increase(mon_backup_failed[15m]) > 0 + +``backup_last_size`` (gauge, bytes) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Payload size in bytes of the most recent backup attempt. + +* Backup size trend across all mons: + + .. code-block:: promql + + mon_backup_last_size + +* Live size check for a single mon via admin socket: + + .. code-block:: bash + + ceph daemon mon. perf dump | jq '.mon.backup_last_size' + +``backup_cleanup_kept`` (gauge) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Number of backups retained by the most recent cleanup pass. + +* Mons whose retention has grown beyond an expected ceiling: + + .. code-block:: promql + + mon_backup_cleanup_kept > 100 + +``backup_cleanup_freed`` (gauge, bytes) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Bytes released by the most recent cleanup pass. Overstated when shared +backups are in use. + +* Bytes reclaimed by the latest cleanup, per mon: + + .. code-block:: promql + + mon_backup_cleanup_freed + +* Total bytes released by the most recent cleanup across all mons: + + .. code-block:: promql + + sum(mon_backup_cleanup_freed) + +Monitor Backup Configuration Options +------------------------------------ + +The following options control monitor backup behavior. All are +runtime-tunable. + +.. confval:: mon_backup_path +.. confval:: mon_backup_min_avail +.. confval:: mon_backup_keep_last +.. confval:: mon_backup_keep_hourly +.. confval:: mon_backup_keep_daily +.. confval:: mon_backup_interval +.. confval:: mon_backup_cleanup_interval + + Miscellaneous ============= diff --git a/doc/rados/troubleshooting/troubleshooting-mon.rst b/doc/rados/troubleshooting/troubleshooting-mon.rst index 10e49dc8b2b..791b8a7b53a 100644 --- a/doc/rados/troubleshooting/troubleshooting-mon.rst +++ b/doc/rados/troubleshooting/troubleshooting-mon.rst @@ -518,6 +518,97 @@ or:: Corruption: 1 missing files; e.g.: /var/lib/ceph/mon/mon.foo/store.db/1234567.ldb +Recovery Using Mon Backup +------------------------- + +If Monitor backups are enabled, backups can be found in the configured +``mon_backup_path``. To list the available backup versions, run: + +.. code-block:: bash + + ceph-mon -i [num] --list-backups /path/to/backups + +In containerized deployments, run this from inside the Monitor +container (``cephadm shell --name mon.``), or install +``ceph-common`` on the host. + +This invokes the RocksDB ``BackupEngine`` to enumerate the logical +backup versions at the path. Output looks like:: + + ID: Time: Size: + 1 Sun May 18 03:00:01 2026 4 MiB + 2 Sun May 18 04:00:02 2026 12 KiB + 3 Sun May 18 05:00:01 2026 16 KiB + +The ``ID`` column is the value to pass as ``--backup-version`` when +restoring. A plain ``ls`` of the backup path shows the BackupEngine's +internal ``meta/``, ``private/``, and ``shared_checksum/`` directories, +which are not directly usable for restore; always use ``--list-backups`` +to obtain the IDs. + +To restore a backup, stop the monitor and run: + +.. code-block:: bash + + ceph-mon -i [num] --restore-backup /path/to/backups --backup-version --yes-i-really-mean-it + +The ``--yes-i-really-mean-it`` flag is required because restore overwrites the existing monitor store. +If the ``--backup-version`` argument is omitted, the latest version will be restored. +The restored store contains everything that was in the mon at the time the backup was taken, +including auth records; any changes (auth, pools, CRUSH, etc.) made after that point are lost. +OSDs will reconcile their state with the restored osdmap as the cluster comes back up. + +If ``ceph-mon --restore-backup`` is invoked as ``root`` (typical when running +from a service shell), the restored ``kv_backend`` file and the rehydrated +``keyring`` will be owned by ``root``. Before starting the monitor daemon, +``chown -R ceph:ceph `` so the unprivileged ``ceph`` user can read +them. + +Restoring a multi-monitor cluster +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Each monitor has its own Paxos state (rank, accepted proposal numbers, +``last_committed``), so backups are per-monitor: a backup taken from +``mon.a`` should be restored into ``mon.a``'s ``mon_data``, not copied +onto ``mon.b`` or ``mon.c``. + +To recover a cluster from monitor backups: + +#. Stop all monitors. +#. Restore each monitor you have a backup for from its own backup path + using the ``--restore-backup`` command above. +#. Start the restored monitors. Quorum forms once a majority of the + monmap members are running. Restoring and starting a majority is the + simplest path: the recovered monmap is the original one, and the + monitors elect among themselves as normal. + +If you cannot bring back a majority of monitors (because backups are +missing or storage is lost on the other hosts), the restored seed will +not form quorum on its own. The monmap recovered from the backup still +lists every monitor in the original cluster, and Paxos requires a +majority to elect. The seed will sit in ``probing`` / ``electing`` +indefinitely. + +To reduce the monmap on the offline seed so it can elect itself: + +#. Stop all monitors. +#. Restore the seed monitor from its backup as above. +#. Edit the monmap directly in the offline mon store:: + + # extract from the offline mon store + ceph-mon -i --extract-monmap /tmp/monmap + # drop monitors that will not come back + monmaptool /tmp/monmap --rm --rm + # write it back into the store + ceph-mon -i --inject-monmap /tmp/monmap + +#. Start the seed monitor. With the reduced monmap it can elect itself + and form a single-member quorum. +#. Re-add any remaining monitors as new sync members per + :ref:`adding-and-removing-monitors`; they will synchronize from the + recovered seed rather than reusing their old backups. + + Recovery Using Healthy Monitor(s) --------------------------------- diff --git a/src/ceph_mon.cc b/src/ceph_mon.cc index e1475368d01..de928f0e4cd 100644 --- a/src/ceph_mon.cc +++ b/src/ceph_mon.cc @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -42,6 +43,7 @@ #include "common/Throttle.h" #include "common/Timer.h" #include "common/errno.h" +#include "common/strtol.h" #include "common/Preforker.h" #include "global/global_init.h" @@ -213,7 +215,7 @@ static void usage() << " --force-sync\n" << " force a sync from another mon by wiping local data (BE CAREFUL)\n" << " --yes-i-really-mean-it\n" - << " mandatory safeguard for --force-sync\n" + << " mandatory safeguard for --force-sync and --restore-backup\n" << " --compact\n" << " compact the monitor store\n" << " --osdmap \n" @@ -224,8 +226,14 @@ static void usage() << " extract the monmap from the local monitor store and exit\n" << " --mon-data \n" << " where the mon store and keyring are located\n" - << " --set-crush-location =" - << " sets monitor's crush bucket location (only for stretch mode)" + << " --set-crush-location =\n" + << " sets monitor's crush bucket location (only for stretch mode)\n" + << " --restore-backup \n" + << " restore the backup from location and exit (requires --yes-i-really-mean-it)\n" + << " --backup-version \n" + << " BackupEngine version ID (uint32); defaults to the latest backup when omitted\n" + << " --list-backups \n" + << " list available backups\n" << std::endl; generic_server_usage(); } @@ -265,6 +273,8 @@ int main(int argc, const char **argv) bool force_sync = false; bool yes_really = false; std::string osdmapfn, inject_monmap, extract_monmap, crush_loc; + std::string restore_backup_location, list_backup_location; + std::optional restore_backup_version; auto args = argv_to_vec(argc, argv); if (args.empty()) { @@ -337,6 +347,18 @@ int main(int argc, const char **argv) extract_monmap = val; } else if (ceph_argparse_witharg(args, i, &val, "--set-crush-location", (char*)NULL)) { crush_loc = val; + } else if (ceph_argparse_witharg(args, i, &val, "--list-backups", (char*)NULL)) { + list_backup_location = val; + } else if (ceph_argparse_witharg(args, i, &val, "--restore-backup", (char*)NULL)) { + restore_backup_location = val; + } else if (ceph_argparse_witharg(args, i, &val, "--backup-version", (char*)NULL)) { + std::string parse_err; + long long v = strict_strtoll(val.c_str(), 10, &parse_err); + if (!parse_err.empty() || v < 0 || v > UINT32_MAX) { + cerr << "invalid --backup-version '" << val << "'" << std::endl; + exit(1); + } + restore_backup_version = static_cast(v); } else { ++i; } @@ -362,6 +384,51 @@ int main(int argc, const char **argv) exit(1); } + // -- list backups -- + if (!list_backup_location.empty()) { + cout << "list backup from location '" << list_backup_location << "'" << std::endl << std::endl; + auto backup_infos = MonitorDBStore::list_backups( + cct.get(), g_conf()->mon_data, list_backup_location); + if (!backup_infos) { + cerr << "failed to enumerate backups at '" << list_backup_location + << "' (see log for details)" << std::endl; + exit(1); + } + if (backup_infos->empty()) { + cout << "no backups found at '" << list_backup_location << "'" << std::endl; + exit(0); + } + cout << "ID:\tTime:\t\t\t\tSize:" << std::endl; + for (const KeyValueDB::BackupStats& bi : *backup_infos) { + cout << bi.id << "\t"; + bi.timestamp.asctime(cout); + cout << "\t" << byte_u_t(bi.size) << std::endl; + } + exit(0); + } + + // -- restore backup -- + if (!restore_backup_location.empty()) { + if (!yes_really) { + cerr << "restoring will overwrite the monitor store at '" << g_conf()->mon_data + << "'. Pass --yes-i-really-mean-it to proceed." << std::endl; + exit(1); + } + cerr << "restoring backup from location '" << restore_backup_location << "' to '" + << g_conf()->mon_data << "'" << std::endl; + if (MonitorDBStore::restore_backup(cct.get(), g_conf()->mon_data, restore_backup_location, restore_backup_version)) { + cout << "successfully restored backup. Start ceph-mon normally" << std::endl; + exit(0); + } + cerr << "restore failed. Check the backup path and version (use --list-backups to enumerate)." << std::endl; + exit(1); + } + + if (restore_backup_version.has_value()) { + cerr << "--backup-version requires --restore-backup" << std::endl; + exit(1); + } + MonitorDBStore store(g_conf()->mon_data); // -- mkfs -- @@ -537,7 +604,7 @@ int main(int argc, const char **argv) exit(1); } store.close(); - dout(0) << argv[0] << ": created monfs at " << g_conf()->mon_data + dout(0) << argv[0] << ": created monfs at " << g_conf()->mon_data << " for " << g_conf()->name << dendl; return 0; } diff --git a/src/common/options/mon.yaml.in b/src/common/options/mon.yaml.in index 9c81dbac734..ef875ef8058 100644 --- a/src/common/options/mon.yaml.in +++ b/src/common/options/mon.yaml.in @@ -1176,6 +1176,74 @@ options: flags: - no_mon_update with_legacy: true +- name: mon_backup_path + type: str + level: advanced + desc: Path to Monitor database backups + fmt_desc: The Monitor's backup location. + default: /var/backups/ceph/mon/$cluster-$id + services: + - mon + flags: + - runtime +- name: mon_backup_min_avail + type: int + level: advanced + desc: Only capture backups if at least this percentage of the target filesystem is free + default: 10 + min: 0 + max: 100 + services: + - mon + flags: + - runtime +- name: mon_backup_keep_last + type: uint + level: advanced + desc: Keep the last N backups + fmt_desc: Keep the last N backups of the Monitor database. + default: 6 + services: + - mon + flags: + - runtime +- name: mon_backup_keep_hourly + type: uint + level: advanced + desc: Number of hourly backups + default: 5 + services: + - mon + flags: + - runtime +- name: mon_backup_keep_daily + type: uint + level: advanced + desc: Number of daily backups + fmt_desc: Keep one backup per day, for the specified number of days. + default: 7 + services: + - mon + flags: + - runtime +- name: mon_backup_interval + type: secs + level: advanced + desc: Automatic backups every N seconds (0 disables) + default: 0 + services: + - mon + flags: + - runtime +- name: mon_backup_cleanup_interval + type: secs + level: advanced + desc: Trigger backup cleanup every N seconds (0 disables) + default: 0 + services: + - mon + flags: + - runtime - name: mon_rocksdb_options type: str level: advanced diff --git a/src/kv/KeyValueDB.cc b/src/kv/KeyValueDB.cc index 41eb30873b8..c7700fd6f3f 100644 --- a/src/kv/KeyValueDB.cc +++ b/src/kv/KeyValueDB.cc @@ -1,6 +1,10 @@ // -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- // vim: ts=8 sw=2 sts=2 expandtab +#include +#include +#include + #include "KeyValueDB.h" #include "RocksDBStore.h" @@ -18,6 +22,52 @@ KeyValueDB *KeyValueDB::create(CephContext *cct, const string& type, return NULL; } +bool KeyValueDB::restore_backup(CephContext *cct, + const std::string &type, + const std::string &path, + const std::string &backup_location, + const std::optional &version) +{ + if (std::filesystem::exists(path) && + !std::filesystem::is_empty(path)) { + std::unique_ptr probe(KeyValueDB::create(cct, type, path)); + if (!probe) { + lderr(cct) << __func__ << " unsupported kv backend: " << type << dendl; + return false; + } + std::ostringstream err; + if (probe->open(err) < 0) { + // Heuristic: rocksdb's PosixEnv lock path surfaces "lock" in the + // error text. Any other open failure (corruption, I/O) is precisely + // why a restore is being run -- warn and proceed. + const std::string msg = err.str(); + if (msg.find("lock") != std::string::npos) { + lderr(cct) << __func__ << " another monitor is using this data dir: " + << path << ": " << msg << dendl; + return false; + } + lderr(cct) << __func__ << " existing store at " << path + << " is unreadable, proceeding with restore: " << msg << dendl; + } else { + probe->close(); + } + } + if (type == "rocksdb") { + return RocksDBStore::restore_backup(cct, path, backup_location, version); + } + return false; +} + +std::optional> KeyValueDB::list_backups( + CephContext *cct, const std::string &type, const std::string &backup_location) +{ + if (type == "rocksdb") { + return RocksDBStore::list_backups(cct, backup_location); + } + lderr(cct) << __func__ << " unsupported kv backend: " << type << dendl; + return std::nullopt; +} + int KeyValueDB::test_init(const string& type, const string& dir) { if (type == "rocksdb") { diff --git a/src/kv/KeyValueDB.h b/src/kv/KeyValueDB.h index 2d861eda82d..381492fa1d7 100644 --- a/src/kv/KeyValueDB.h +++ b/src/kv/KeyValueDB.h @@ -13,6 +13,7 @@ #include #include #include "include/encoding.h" +#include "include/utime.h" #include "common/Formatter.h" #include "common/perf_counters.h" #include "common/PriorityCache.h" @@ -24,6 +25,25 @@ */ class KeyValueDB { public: + struct BackupCleanupStats { + bool error{false}; + utime_t timestamp; + uint32_t corrupted{0}; + uint32_t deleted{0}; + uint32_t kept{0}; + uint64_t size{0}; + uint64_t freed{0}; + }; + + struct BackupStats { + bool error{false}; + uint64_t id{0}; + utime_t timestamp; + std::string msg; + uint64_t size{0}; + uint64_t number_files{0}; + }; + class TransactionImpl { public: // amount of ops included @@ -112,8 +132,8 @@ public: } /// Remove Single Key which exists and was not overwritten. - /// This API is only related to performance optimization, and should only be - /// re-implemented by log-insert-merge tree based keyvalue stores(such as RocksDB). + /// This API is only related to performance optimization, and should only be + /// re-implemented by log-insert-merge tree based keyvalue stores(such as RocksDB). /// If a key is overwritten (by calling set multiple times), then the result /// of calling rm_single_key on this key is undefined. virtual void rm_single_key( @@ -418,6 +438,29 @@ public: return 0; }; + /// Create a kv database backup in directory path. + virtual BackupStats backup(const std::string &path) { + return {.error = true, .msg = "backup not supported by this backend"}; + } + + /// Remove old backups in directory path according to retention. + virtual BackupCleanupStats backup_cleanup(const std::string &path, + uint64_t keep_last, + uint64_t keep_hourly, + uint64_t keep_daily) { + return {.error = true}; + } + + /// restore from backup the specified backup version + static bool restore_backup(CephContext *cct, const std::string &type, + const std::string &path, + const std::string &backup_location, + const std::optional &version); + + static std::optional> list_backups( + CephContext *cct, const std::string &type, + const std::string &backup_location); + /// compact the underlying store virtual void compact() {} diff --git a/src/kv/RocksDBStore.cc b/src/kv/RocksDBStore.cc index ae308fafdf6..6d9470306a1 100644 --- a/src/kv/RocksDBStore.cc +++ b/src/kv/RocksDBStore.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -18,10 +19,14 @@ #include "rocksdb/slice.h" #include "rocksdb/cache.h" #include "rocksdb/filter_policy.h" +#include "rocksdb/utilities/backup_engine.h" #include "rocksdb/utilities/convenience.h" #include "rocksdb/utilities/table_properties_collectors.h" #include "rocksdb/merge_operator.h" +#include "common/version.h" +#include "rocksdb/util/stderr_logger.h" + #include "common/Clock.h" // for ceph_clock_now() #include "common/perf_counters.h" #include "common/PriorityCache.h" @@ -70,6 +75,7 @@ static const char* sharding_def_file = "sharding/def"; static const char* sharding_recreate = "sharding/recreate_columns"; static const char* resharding_column_lock = "reshardingXcommencingXlocked"; + static bufferlist to_bufferlist(rocksdb::Slice in) { bufferlist bl; bl.append(bufferptr(in.data(), in.size())); @@ -1143,6 +1149,7 @@ int RocksDBStore::do_open(ostream &out, if (create_if_missing) { status = rocksdb::DB::Open(opt, path, &db); if (!status.ok()) { + out << status.ToString(); derr << status.ToString() << dendl; return -EINVAL; } @@ -1191,6 +1198,7 @@ int RocksDBStore::do_open(ostream &out, status = rocksdb::DB::Open(opt, path, &db); } if (!status.ok()) { + out << status.ToString(); derr << status.ToString() << dendl; return -EINVAL; } @@ -1206,6 +1214,7 @@ int RocksDBStore::do_open(ostream &out, path, existing_cfs, &handles, &db); } if (!status.ok()) { + out << status.ToString(); derr << status.ToString() << dendl; return -EINVAL; } @@ -2076,6 +2085,278 @@ int RocksDBStore::split_key(rocksdb::Slice in, string_view *prefix, string_view return 0; } +KeyValueDB::BackupStats RocksDBStore::backup(const std::string &path) +{ + ldout(cct, 20) << __func__ << " start backup action" << dendl; + std::lock_guard backup_locker{backup_lock}; + // stamp timestamp up front so every return path (including early Open + // failures) carries a real time the scheduler can gate retries on. + KeyValueDB::BackupStats rv; + rv.timestamp = ceph_clock_now(); + + rocksdb::BackupEngine* engine_ptr = nullptr; + rocksdb::BackupEngineOptions engine_options = rocksdb::BackupEngineOptions(path); + // BackupEngineOptions must be stable across opens to the same directory, + // and share_files_with_checksum=false is deprecated by rocksdb. + engine_options.share_table_files = true; + engine_options.share_files_with_checksum = true; + engine_options.sync = true; + + rocksdb::Status s = rocksdb::BackupEngine::Open( + engine_options, + rocksdb::Env::Default(), + &engine_ptr); + std::unique_ptr backup_engine{engine_ptr}; + + if (!backup_engine || !s.ok()) { + ldout(cct, 0) << __func__ << " can't create backup_engine: " << s.ToString() << dendl; + rv.msg = s.ToString(); + rv.error = true; + return rv; + } + + // we remove corrupted backups first to not link to broken ones + remove_corrupted_backups(backup_engine.get(), nullptr); + + rocksdb::BackupID new_backup; + rocksdb::BackupInfo new_backup_info; + rocksdb::CreateBackupOptions new_backup_options = rocksdb::CreateBackupOptions(); + new_backup_options.flush_before_backup = true; + + std::string app_metadata = std::string("ceph_version=") + ceph_version_to_str(); + s = backup_engine->CreateNewBackupWithMetadata(new_backup_options, db, app_metadata, &new_backup); + + rv.timestamp = ceph_clock_now(); + rv.msg = s.ToString(); + + if (!s.ok()) { + ldout(cct, 0) << __func__ << " can't create backup: " << s.ToString() << dendl; + rv.error = true; + remove_corrupted_backups(backup_engine.get(), nullptr); + return rv; + } else { + ldout(cct, 10) << __func__ << " created backup successfully: " << s.ToString() << dendl; + rv.msg = s.ToString(); + } + s = backup_engine->GetBackupInfo(new_backup, &new_backup_info); + if (!s.ok()) { + ldout(cct, 0) << __func__ << " can't get backup info: " << s.ToString() << dendl; + rv.error = true; + rv.msg = s.ToString(); + return rv; + } + rv.id = new_backup_info.backup_id; + rv.size = new_backup_info.size; + rv.number_files = new_backup_info.number_files; + + return rv; +} + +bool RocksDBStore::restore_backup(CephContext *cct, const std::string &path, + const std::string &backup_location, + const std::optional &version) +{ + rocksdb::BackupEngineReadOnly* engine_ptr = nullptr; + rocksdb::StderrLogger logger = rocksdb::StderrLogger(); + rocksdb::BackupEngineOptions engine_options = rocksdb::BackupEngineOptions(backup_location); + engine_options.info_log = &logger; + + rocksdb::Status s = rocksdb::BackupEngineReadOnly::Open( + rocksdb::Env::Default(), + engine_options, + &engine_ptr); + std::unique_ptr backup_engine{engine_ptr}; + const rocksdb::RestoreOptions options = rocksdb::RestoreOptions(); + if (!s.ok()) { + derr << __func__ << " can't open backup folder: " << s.ToString() << dendl; + return false; + } + if (!version) { + derr << __func__ << " restore last valid backup" << dendl; + s = backup_engine->RestoreDBFromLatestBackup(options, path, path); + } else { + s = backup_engine->RestoreDBFromBackup( + options, static_cast(*version), path, path); + } + if (!s.ok()) { + derr << "Error when restoring backup: " << s.ToString() << dendl; + } + return s.ok(); +} + +namespace { + +bool compare_backupinfo_by_timestamp(const rocksdb::BackupInfo& a, const rocksdb::BackupInfo& b) +{ + // newest first; tie-break on backup_id so cleanup never keeps an older entry + // in preference to a newer one with the same second-resolution timestamp. + return std::tie(a.timestamp, a.backup_id) > std::tie(b.timestamp, b.backup_id); +} + +struct TimeBucket { + utime_t start; + utime_t end; + rocksdb::BackupID backup_id; + + TimeBucket(utime_t start, utime_t end) : + start(start), end(end), backup_id(0) {} +}; + +} // namespace + +void RocksDBStore::remove_corrupted_backups(rocksdb::BackupEngine *backup_engine, KeyValueDB::BackupCleanupStats *rv) { + std::vector corrupt_backup_ids; + backup_engine->GetCorruptedBackups(&corrupt_backup_ids); + for (rocksdb::BackupID backup_id : corrupt_backup_ids) { + ldout(cct, 1) << __func__ << " delete corrupted backup: " << backup_id << dendl; + rocksdb::Status s = backup_engine->DeleteBackup(backup_id); + if (!s.ok()) { + lderr(cct) << __func__ << " failed to delete corrupted backup " + << backup_id << ": " << s.ToString() << dendl; + if (rv) { + rv->error = true; + } + continue; + } + if (rv) { + rv->corrupted++; + } + } +} + +KeyValueDB::BackupCleanupStats RocksDBStore::backup_cleanup(const std::string &path, + uint64_t keep_last, + uint64_t keep_hourly, + uint64_t keep_daily) +{ + ldout(cct, 20) << __func__ << " start backup cleanup" << dendl; + std::lock_guard backup_locker{backup_lock}; + // stamp timestamp up front so every return path (including early Open + // failures and empty result) carries a real time for the retry gate. + BackupCleanupStats rv; + rv.timestamp = ceph_clock_now(); + + rocksdb::BackupEngine* engine_ptr = nullptr; + rocksdb::Status s = rocksdb::BackupEngine::Open( + rocksdb::BackupEngineOptions(path), + rocksdb::Env::Default(), + &engine_ptr); + std::unique_ptr backup_engine{engine_ptr}; + if (!backup_engine || !s.ok()) { + // cleaning backups when folder is not available is minor problem + ldout(cct, 10) << __func__ << " can't clean backups: " << s.ToString() << dendl; + rv.error = true; + return rv; + } + // remove corrupted backups first + std::set keep_backups; + + remove_corrupted_backups(backup_engine.get(), &rv); + ldout(cct, 20) << __func__ << " collect garbage" << dendl; + + std::vector backup_infos; + backup_engine->GetBackupInfo(&backup_infos); + + if (backup_infos.empty()) { + ldout(cct, 15) << __func__ << " no backup infos" << dendl; + return rv; + } + // sort all backups with newest backup first + std::stable_sort(backup_infos.begin(), backup_infos.end(), compare_backupinfo_by_timestamp); + // always retain the newest backup, regardless of retention settings, so + // cleanup can never leave zero backups for a subsequent failed backup. + keep_backups.insert(backup_infos.front().backup_id); + utime_t now = ceph_clock_now(); + + std::vector buckets; + // half-open intervals [start, end) so adjacent buckets meet without gaps + utime_t start = now.round_to_hour(); + for (uint64_t i = 0; i < keep_hourly; i++) { + buckets.push_back(TimeBucket(start, start + utime_t(3600, 0))); + start -= 3600.0; + } + start = now.round_to_day(); + for (uint64_t i = 0; i < keep_daily; i++) { + buckets.push_back(TimeBucket(start, start + utime_t(86400, 0))); + start -= 86400.0; + } + + size_t i = 0; + for (const rocksdb::BackupInfo& bi : backup_infos) { + if (i++ < keep_last) { + keep_backups.insert(bi.backup_id); + } + utime_t ts = utime_t(bi.timestamp, 0); + for (TimeBucket& bucket : buckets) { + if (ts >= bucket.start && ts < bucket.end) { + if (bucket.backup_id == 0) { + bucket.backup_id = bi.backup_id; + } + } + } + } + // push the winners into the list + for (const TimeBucket& bucket : buckets) { + if (bucket.backup_id) { + keep_backups.insert(bucket.backup_id); + } + } + + rv.kept = keep_backups.size(); + + for (const rocksdb::BackupInfo& bi : backup_infos) { + if (keep_backups.count(bi.backup_id)) { + // payload-sum across kept backups (does not account for file sharing, + // so it overstates the on-disk footprint). + rv.size += bi.size; + continue; + } + ldout(cct, 10) << __func__ << " delete old backup: " << bi.backup_id << dendl; + rocksdb::Status s = backup_engine->DeleteBackup(bi.backup_id); + if (!s.ok()) { + lderr(cct) << __func__ << " failed to delete backup " << bi.backup_id + << ": " << s.ToString() << dendl; + rv.error = true; + continue; + } + rv.freed += bi.size; + rv.deleted++; + } + rv.timestamp = ceph_clock_now(); + return rv; +} + +std::optional> +RocksDBStore::list_backups(CephContext *cct, const std::string &backup_location) { + rocksdb::BackupEngineReadOnly* engine_ptr = nullptr; + rocksdb::Status s = rocksdb::BackupEngineReadOnly::Open( + rocksdb::BackupEngineOptions(backup_location), + rocksdb::Env::Default(), + &engine_ptr); + std::unique_ptr backup_engine{engine_ptr}; + + if (!backup_engine || !s.ok()) { + lderr(cct) << __func__ << " can't open backup location " << backup_location + << ": " << s.ToString() << dendl; + return std::nullopt; + } + + std::vector backup_infos; + backup_engine->GetBackupInfo(&backup_infos); + std::stable_sort(backup_infos.begin(), backup_infos.end(), compare_backupinfo_by_timestamp); + std::vector rv; + for (const rocksdb::BackupInfo& bi : backup_infos) { + KeyValueDB::BackupStats br; + br.id = bi.backup_id; + br.timestamp = utime_t(bi.timestamp, 0); + br.size = bi.size; + br.number_files = bi.number_files; + rv.push_back(br); + } + return rv; +} + + void RocksDBStore::compact() { dout(2) << __func__ << " starting" << dendl; @@ -3386,7 +3667,7 @@ int RocksDBStore::prepare_for_reshard(const std::string& new_sharding, << full_name << dendl; return -EINVAL; } - dout(10) << "created column " << full_name << " handle = " << (void*)cf << dendl; + dout(10) << "created column " << full_name << " handle = " << (void*)cf << dendl; existing_columns.push_back(full_name); handles.push_back(cf); } diff --git a/src/kv/RocksDBStore.h b/src/kv/RocksDBStore.h index e4dd5ca54c5..2bd310aa44f 100644 --- a/src/kv/RocksDBStore.h +++ b/src/kv/RocksDBStore.h @@ -21,6 +21,7 @@ #include "rocksdb/statistics.h" #include "rocksdb/table.h" #include "rocksdb/db.h" +#include "rocksdb/utilities/backup_engine.h" #include "kv/rocksdb_cache/BinnedLRUCache.h" #include #include "common/errno.h" @@ -115,11 +116,13 @@ public: uint32_t hash_l, uint32_t hash_h) : name(name), shard_cnt(shard_cnt), options(options), hash_l(hash_l), hash_h(hash_h) {} }; + private: friend std::ostream& operator<<(std::ostream& out, const ColumnFamily& cf); bool must_close_default_cf = false; rocksdb::ColumnFamilyHandle *default_cf = nullptr; + ceph::mutex backup_lock = ceph::make_mutex("RocksDBStore::Backup"); /// column families in use, name->handles struct prefix_shards { @@ -147,6 +150,7 @@ private: int do_open(std::ostream &out, bool create_if_missing, bool open_readonly, const std::string& cfs=""); int load_rocksdb_options(bool create_if_missing, rocksdb::Options& opt); + void remove_corrupted_backups(rocksdb::BackupEngine *engine, KeyValueDB::BackupCleanupStats *result); public: static bool parse_sharding_def(const std::string_view text_def, std::vector& sharding_def, @@ -212,6 +216,23 @@ public: return cct->_conf.get_val("rocksdb_delete_range_threshold"); } + KeyValueDB::BackupStats backup(const std::string &path) override; + KeyValueDB::BackupCleanupStats backup_cleanup(const std::string &path, + uint64_t keep_last, + uint64_t keep_hourly, + uint64_t keep_daily) override; + + /// Restore a backup into @p path. @p version is the rocksdb backup id, or + /// nullopt for the most recent. Must be called on a closed store. + static bool restore_backup(CephContext *cct, const std::string &path, + const std::string &backup_location, + const std::optional &version); + + /// List backups at @p backup_location, newest first. + /// Returns nullopt if the BackupEngine could not be opened. + static std::optional> list_backups( + CephContext *cct, const std::string &backup_location); + void compact() override; void compact_async() override { diff --git a/src/mon/CMakeLists.txt b/src/mon/CMakeLists.txt index 34f48e55424..94944d792c1 100644 --- a/src/mon/CMakeLists.txt +++ b/src/mon/CMakeLists.txt @@ -10,6 +10,7 @@ set(lib_mon_srcs MgrMonitor.cc MgrStatMonitor.cc Monitor.cc + MonitorBackup.cc MonmapMonitor.cc LogMonitor.cc AuthMonitor.cc diff --git a/src/mon/Monitor.cc b/src/mon/Monitor.cc index 205fb23c7ce..8ef4bdbbd3d 100644 --- a/src/mon/Monitor.cc +++ b/src/mon/Monitor.cc @@ -41,6 +41,7 @@ #include "MonitorDBStore.h" #include "MonMap.h" #include "Paxos.h" +#include "MonitorBackup.h" #include "messages/PaxosServiceMessage.h" #include "messages/MMonCommand.h" @@ -549,7 +550,11 @@ will start to track new ops received afterwards."; << duration << " seconds" << dendl; out << "compacted " << g_conf().get_val("mon_keyvaluedb") << " in " << duration << " seconds"; - } else { + } else if (command == "backup") { + r = perform_backup(); + } else if (command == "backup_cleanup") { + r = cleanup_backup(); + } else { ceph_abort_msg("bad AdminSocket command binding"); } (read_only ? audit_clog->debug() : audit_clog->info()) @@ -568,6 +573,40 @@ abort: return r; } +int Monitor::perform_backup() +{ + std::string backup_path = g_conf().get_val("mon_backup_path"); + dout(1) << "triggering backup" << dendl; + if (backup_path.empty()) { + derr << "backup failed: mon_backup_path is empty" << dendl; + return -ENOTDIR; + } + if (!backup_manager) { + derr << "backup failed: monitor still initializing" << dendl; + return -EAGAIN; + } + uint64_t jobid = backup_manager->backup(); + dout(1) << "queued backup job id " << jobid << dendl; + return 0; +} + +int Monitor::cleanup_backup() +{ + std::string backup_path = g_conf().get_val("mon_backup_path"); + dout(1) << "triggering backup_cleanup" << dendl; + if (backup_path.empty()) { + derr << "backup_cleanup failed: mon_backup_path is empty" << dendl; + return -ENOTDIR; + } + if (!backup_manager) { + derr << "backup_cleanup failed: monitor still initializing" << dendl; + return -EAGAIN; + } + uint64_t jobid = backup_manager->cleanup(); + dout(1) << "queued backup cleanup job id " << jobid << dendl; + return 0; +} + void Monitor::handle_signal(int signum) { derr << "*** Got Signal " << sig_str(signum) << " ***" << dendl; @@ -825,6 +864,44 @@ int Monitor::preinit() "ewon", PerfCountersBuilder::PRIO_INTERESTING); pcb.add_u64_counter(l_mon_election_lose, "election_lose", "Elections lost", "elst", PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_running, "backup_running", "Mon backup process is running", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64_counter(l_mon_backup_started, "backup_started", "Mon backups started", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64_counter(l_mon_backup_success, "backup_success", "Mon backups finished successfully", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64_counter(l_mon_backup_failed, "backup_failed", "Mon backups failed", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_time_avg(l_mon_backup_duration, "backup_duration", "Mon backup duration", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_time(l_mon_backup_last_success, "backup_last_success", "Last successful mon backup", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64(l_mon_backup_last_success_id, "backup_last_success_id", "Last successful mon backup id", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_time(l_mon_backup_last_failed, "backup_last_failed", "Last failed mon backup", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64(l_mon_backup_last_size, "backup_last_size", "Last backup size", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_last_files, "backup_last_files", "Last backup file numbers", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64_counter(l_mon_backup_cleanup_started, "backup_cleanup_started", "Mon backup cleanup started", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_cleanup_running, "backup_cleanup_running", "Mon backup cleanup is running", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64_counter(l_mon_backup_cleanup_success, "backup_cleanup_success", "Mon backup cleanup finished successfully", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64_counter(l_mon_backup_cleanup_failed, "backup_cleanup_failed", "Mon backup cleanup failed", + nullptr, PerfCountersBuilder::PRIO_USEFUL); + pcb.add_u64(l_mon_backup_cleanup_size, "backup_cleanup_size", "Size of backups removed", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_cleanup_kept, "backup_cleanup_kept", "Number of backups kept after cleanup", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_time_avg(l_mon_backup_cleanup_duration, "backup_cleanup_duration", "Mon backup cleanup duration", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_cleanup_freed, "backup_cleanup_freed", "Mon backup cleanup freed size in bytes", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); + pcb.add_u64(l_mon_backup_cleanup_deleted, "backup_cleanup_deleted", "Mon backup cleanup deleted backups", + nullptr, PerfCountersBuilder::PRIO_INTERESTING); logger = pcb.create_perf_counters(); cct->get_perfcounters_collection()->add(logger); } @@ -983,6 +1060,13 @@ int Monitor::preinit() command.helpstring); ceph_assert(r == 0); } + r = admin_socket->register_command("backup", admin_hook, + "create a backup of the mon database"); + ceph_assert(r == 0); + r = admin_socket->register_command( + "backup_cleanup", admin_hook, + "delete old mon database backups according to retention config"); + ceph_assert(r == 0); l.lock(); // add ourselves as a conf observer @@ -1031,6 +1115,9 @@ int Monitor::init() // add features of myself into feature_map session_map.feature_map.add_mon(con_self->get_features()); + + backup_manager = std::make_unique(cct, this); + return 0; } @@ -1126,6 +1213,9 @@ void Monitor::shutdown() delete admin_hook; admin_hook = NULL; } + if (backup_manager) { + backup_manager->stop(); + } elector.shutdown(); @@ -6119,6 +6209,7 @@ void Monitor::tick() prepare_new_fingerprint(t); paxos->trigger_propose(); } + backup_manager->tick(); mgr_client.update_daemon_health(get_health_metrics()); new_tick(); diff --git a/src/mon/Monitor.h b/src/mon/Monitor.h index cc7d7b12e02..a914969ed35 100644 --- a/src/mon/Monitor.h +++ b/src/mon/Monitor.h @@ -50,6 +50,7 @@ #include "include/CompatSet.h" #include "mon/MonitorDBStore.h" #include "mon/mon_types.h" // for Metadata, PAXOS_*, ScrubResult +#include "mon/MonitorBackup.h" #include "mgr/MgrClient.h" #include #include @@ -100,6 +101,25 @@ enum { l_mon_election_call, l_mon_election_win, l_mon_election_lose, + l_mon_backup_running, + l_mon_backup_started, + l_mon_backup_success, + l_mon_backup_failed, + l_mon_backup_duration, + l_mon_backup_last_success, + l_mon_backup_last_success_id, + l_mon_backup_last_failed, + l_mon_backup_last_size, + l_mon_backup_last_files, + l_mon_backup_cleanup_started, + l_mon_backup_cleanup_running, + l_mon_backup_cleanup_success, + l_mon_backup_cleanup_failed, + l_mon_backup_cleanup_size, + l_mon_backup_cleanup_kept, + l_mon_backup_cleanup_duration, + l_mon_backup_cleanup_freed, + l_mon_backup_cleanup_deleted, l_mon_last, }; @@ -1001,6 +1021,8 @@ private: OpTracker op_tracker; + std::unique_ptr backup_manager; + public: Monitor(CephContext *cct_, std::string nm, MonitorDBStore *s, Messenger *m, Messenger *mgr_m, MonMap *map); @@ -1046,6 +1068,10 @@ private: std::ostream& err, std::ostream& out); + // Execute mon database backup + int perform_backup(); + int cleanup_backup(); + private: // don't allow copying Monitor(const Monitor& rhs); diff --git a/src/mon/MonitorBackup.cc b/src/mon/MonitorBackup.cc new file mode 100644 index 00000000000..aff1bebdaf8 --- /dev/null +++ b/src/mon/MonitorBackup.cc @@ -0,0 +1,214 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab +/* +* Ceph - scalable distributed file system +* +* Copyright (C) 2021 B1-Systems GmbH +* +* This is free software; you can redistribute it and/or +* modify it under the terms of the GNU Lesser General Public +* License version 2.1, as published by the Free Software +* Foundation. See file COPYING. +*/ + +#include +#include + +#include "include/util.h" +#include "mon/MonitorBackup.h" +#include "mon/Monitor.h" + +#define dout_subsys ceph_subsys_mon +#undef dout_context +#define dout_context cct + +namespace fs = std::filesystem; + +/*** + * Thread which runs monitor backup operations + */ +void *MonitorBackupManager::entry() { + std::unique_lock lock{mutex}; + auto wakeup_predicate = [this] { + return should_stop || should_backup || should_cleanup || wakeup_pending; + }; + while (true) { + // Wait for any signal (tick, request, or stop) before doing + // scheduled work. Predicate-based so notifications delivered + // before the worker entered wait() are not lost, and so we don't + // fire scheduled work while init() is still wiring the mon up. + work_cond.wait(lock, wakeup_predicate); + if (should_stop) { + return nullptr; + } + wakeup_pending = false; + + auto now = ceph_clock_now(); + std::string backup_path = cct->_conf.get_val("mon_backup_path"); + auto interval = cct->_conf.get_val("mon_backup_interval"); + auto cleanup_interval = cct->_conf.get_val("mon_backup_cleanup_interval"); + bool path_ok = !backup_path.empty(); + + bool timer_backup = false; + bool timer_cleanup = false; + + if (path_ok && interval.count() > 0 && + (mon->is_leader() || mon->is_peon())) { + if (!last_backup) { + dout(10) << "trigger first timed backup" << dendl; + timer_backup = true; + } else if ((now - last_backup->timestamp) >= utime_t(interval.count(), 0)) { + dout(10) << "trigger timed backup" << dendl; + timer_backup = true; + } + } + if (path_ok && cleanup_interval.count() > 0) { + if (!last_cleanup) { + dout(10) << "trigger first timed backup cleanup" << dendl; + timer_cleanup = true; + } else if ((now - last_cleanup->timestamp) >= utime_t(cleanup_interval.count(), 0)) { + dout(10) << "trigger timed backup cleanup" << dendl; + timer_cleanup = true; + } + } + + bool run_cleanup = should_cleanup || timer_cleanup; + bool run_backup = should_backup || timer_backup; + + should_backup = false; + should_cleanup = false; + + if (!run_backup && !run_cleanup) { + continue; + } + + lock.unlock(); + if (run_cleanup) { + do_cleanup(); + } + if (run_backup) { + do_backup(); + } + lock.lock(); + } +} + +void MonitorBackupManager::stop() { + { + std::lock_guard guard{mutex}; + if (should_stop) { + return; + } + should_stop = true; + work_cond.notify_one(); + } + join(); +} + +void MonitorBackupManager::do_cleanup() { + dout(5) << "start backup cleanup" << dendl; + mon->logger->inc(l_mon_backup_cleanup_started); + mon->logger->set(l_mon_backup_cleanup_running, 1); + auto start = ceph_clock_now(); + KeyValueDB::BackupCleanupStats stats = mon->store->backup_cleanup(); + mon->logger->set(l_mon_backup_cleanup_size, stats.size); + mon->logger->set(l_mon_backup_cleanup_kept, stats.kept); + mon->logger->set(l_mon_backup_cleanup_freed, stats.freed); + mon->logger->set(l_mon_backup_cleanup_deleted, stats.deleted); + if (stats.error) { + mon->logger->inc(l_mon_backup_cleanup_failed); + } else { + mon->logger->inc(l_mon_backup_cleanup_success); + } + auto ptr = std::make_shared(stats); + last_cleanup.swap(ptr); + auto end = ceph_clock_now(); + utime_t duration = end - start; + mon->logger->tinc(l_mon_backup_cleanup_duration, duration); + mon->logger->set(l_mon_backup_cleanup_running, 0); +} + +void MonitorBackupManager::record_last_backup(std::shared_ptr stats) { + if (stats->error && last_backup) { + stats->id = last_backup->id; + } + last_backup.swap(stats); +} + +// Returns true if there is enough free space on the backup volume. +bool MonitorBackupManager::check_free_space() { + auto backup_path = cct->_conf.get_val("mon_backup_path"); + + std::error_code ec; + if (!fs::exists(backup_path, ec)) { + if (!fs::create_directories(backup_path, ec)) { + dout(1) << "failed to create monitor backup directory '" + << backup_path << "': " << ec.message() << dendl; + return false; + } + fs::permissions(backup_path, fs::perms::owner_all, + fs::perm_options::replace, ec); + if (ec) { + dout(1) << "failed to set permissions on monitor backup directory '" + << backup_path << "': " << ec.message() << dendl; + return false; + } + dout(5) << "created monitor backup directory '" << backup_path + << "'" << dendl; + } + + ceph_data_stats_t stats; + int err = get_fs_stats(stats, backup_path.c_str()); + if (err < 0) { + dout(1) << "error checking monitor backup directory: " << cpp_strerror(err) + << dendl; + return false; + } + + if (stats.avail_percent <= cct->_conf.get_val("mon_backup_min_avail")) { + dout(1) << "ERROR: not enough disk space to start backup: " << "(available: " + << stats.avail_percent << "% " << byte_u_t(stats.byte_avail) << ")\n" + << "run backup_cleanup regularly or decrease mon_backup_min_avail" << dendl; + return false; + } + return true; +} + +void MonitorBackupManager::do_backup() { + dout(1) << "start backup" << dendl; + mon->logger->inc(l_mon_backup_started); + mon->logger->set(l_mon_backup_running, 1); + auto start = ceph_clock_now(); + + std::shared_ptr result; + + if (!check_free_space()) { + mon->logger->inc(l_mon_backup_failed); + mon->logger->tset(l_mon_backup_last_failed, start); + result = std::make_shared(); + result->error = true; + result->timestamp = start; + result->msg = "insufficient free space"; + } else { + KeyValueDB::BackupStats stats = mon->store->backup(); + utime_t duration = ceph_clock_now() - start; + mon->logger->tinc(l_mon_backup_duration, duration); + mon->logger->set(l_mon_backup_last_size, stats.size); + mon->logger->set(l_mon_backup_last_files, stats.number_files); + if (stats.error) { + mon->logger->inc(l_mon_backup_failed); + mon->logger->tset(l_mon_backup_last_failed, stats.timestamp); + dout(1) << "failed backup in " << utimespan_str(duration) << dendl; + } else { + mon->logger->inc(l_mon_backup_success); + mon->logger->tset(l_mon_backup_last_success, stats.timestamp); + mon->logger->set(l_mon_backup_last_success_id, stats.id); + dout(1) << "finished backup in " << utimespan_str(duration) << dendl; + } + result = std::make_shared(stats); + } + + record_last_backup(result); + mon->logger->set(l_mon_backup_running, 0); +} + diff --git a/src/mon/MonitorBackup.h b/src/mon/MonitorBackup.h new file mode 100644 index 00000000000..74d5fc879b5 --- /dev/null +++ b/src/mon/MonitorBackup.h @@ -0,0 +1,101 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab +/* +* Ceph - scalable distributed file system +* +* Copyright (C) 2021 B1-Systems GmbH +* +* This is free software; you can redistribute it and/or +* modify it under the terms of the GNU Lesser General Public +* License version 2.1, as published by the Free Software +* Foundation. See file COPYING. +*/ + + +#ifndef CEPH_MONITOR_BACKUP_H +#define CEPH_MONITOR_BACKUP_H + +#include +#include +#include +#include + + +#include "common/Thread.h" +#include "common/ceph_context.h" +#include "common/ceph_mutex.h" +#include "kv/KeyValueDB.h" +#include "mon/MonitorDBStore.h" + +class Monitor; + +class MonitorBackupManager : public Thread { + CephContext *cct; + Monitor *mon; + ceph::mutex mutex; + ceph::condition_variable work_cond; + bool should_stop{false}; + // set by tick(); a sticky flag so a notification delivered before the + // worker enters wait() is not lost. cleared each time the worker + // re-evaluates timer triggers. + bool wakeup_pending{false}; + + bool should_backup{false}; + bool should_cleanup{false}; + uint64_t last_job_id{0}; + std::shared_ptr last_cleanup; + std::shared_ptr last_backup; + + void do_backup(); + void do_cleanup(); + bool check_free_space(); + void record_last_backup(std::shared_ptr stats); +protected: + void *entry() override; + +public: + explicit MonitorBackupManager(CephContext *cct, Monitor *monitor) : + cct(cct), + mon(monitor), + mutex(ceph::make_mutex("mon::BackupManager::mutex")) { + create("mon::backups"); + } + + void tick() { + std::lock_guard guard{mutex}; + if (should_stop) { + return; + } + wakeup_pending = true; + work_cond.notify_one(); + } + + /** + * Stop the backup manager thread. Safe to call more than once. + **/ + void stop(); + /** + * Start a new backup. + * @returns {uint64_t} new job id + **/ + uint64_t backup() { + std::lock_guard guard{mutex}; + should_backup = true; + uint64_t rv = ++last_job_id; + work_cond.notify_one(); + return rv; + } + + /// Queue a cleanup pass. + uint64_t cleanup() { + std::lock_guard guard{mutex}; + should_cleanup = true; + uint64_t rv = ++last_job_id; + work_cond.notify_one(); + return rv; + } + +}; + +#endif + diff --git a/src/mon/MonitorDBStore.h b/src/mon/MonitorDBStore.h index a645bb7e4ae..c316d23768b 100644 --- a/src/mon/MonitorDBStore.h +++ b/src/mon/MonitorDBStore.h @@ -14,6 +14,8 @@ #ifndef CEPH_MONITOR_DB_STORE_H #define CEPH_MONITOR_DB_STORE_H +#include +#include #include #include #include @@ -32,6 +34,7 @@ #include "common/Clock.h" #include "common/debug.h" #include "common/safe_io.h" +#include "common/strtol.h" #include "common/blkdev.h" #include "common/PriorityCache.h" #include "common/version.h" @@ -65,6 +68,11 @@ class MonitorDBStore return path; } + // returns the database store path + static std::string get_store_path(const std::string& path) { + return (std::filesystem::path(path) / "store.db").string(); + } + std::shared_ptr get_priority_cache() const { return db->get_priority_cache(); } @@ -631,14 +639,7 @@ class MonitorDBStore } void _open(const std::string& kv_type) { - int pos = 0; - for (auto rit = path.rbegin(); rit != path.rend(); ++rit, ++pos) { - if (*rit != '/') - break; - } - std::ostringstream os; - os << path.substr(0, path.size() - pos) << "/store.db"; - std::string full_path = os.str(); + std::string full_path = get_store_path(path); KeyValueDB *db_ptr = KeyValueDB::create(g_ceph_context, kv_type, @@ -691,7 +692,7 @@ class MonitorDBStore if (r < 0) return r; - // Monitors are few in number, so the resource cost of exposing + // Monitors are few in number, so the resource cost of exposing // very detailed stats is low: ramp up the priority of all the // KV store's perf counters. Do this after open, because backend may // not have constructed PerfCounters earlier. @@ -743,6 +744,200 @@ class MonitorDBStore db.reset(NULL); } + /// @brief Creates a backup of the database under mon_backup_path. + /// @return stats describing the created backup + KeyValueDB::BackupStats backup() { + auto backup_path = g_conf().get_val("mon_backup_path"); + auto stats = db->backup(backup_path); + if (!stats.error) { + // Stash the mon keyring alongside the rocksdb backup, keyed by + // backup id, so a restore of an older version is paired with the + // keyring of that vintage. The [mon.] secret can rotate; using a + // single fixed filename would break authentication after restore. + std::error_code ec; + auto dest = backup_path + "/keyring." + std::to_string(stats.id); + std::filesystem::copy_file( + path + "/keyring", dest, + std::filesystem::copy_options::overwrite_existing, ec); + if (!ec) { + std::filesystem::permissions(dest, + std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, + std::filesystem::perm_options::replace, ec); + } + if (ec) { + // Best-effort: a rocksdb backup without a stashed keyring is still + // valid; the operator can supply a keyring out-of-band on restore. + derr << __func__ << " failed to stash keyring at " + << dest << ": " << ec.message() << dendl; + } + } + return stats; + } + + /// @brief Remove old backups in mon_backup_path according to the retention config. + /// @return stats describing what was kept, deleted, and freed + KeyValueDB::BackupCleanupStats backup_cleanup() { + auto backup_path = g_conf().get_val("mon_backup_path"); + auto stats = db->backup_cleanup( + backup_path, + g_conf().get_val("mon_backup_keep_last"), + g_conf().get_val("mon_backup_keep_hourly"), + g_conf().get_val("mon_backup_keep_daily")); + if (stats.error) { + return stats; + } + // Remove keyring. files for backup ids the kv layer just dropped. + std::string kv_type; + if (read_meta("kv_backend", &kv_type) < 0 || kv_type.empty()) { + kv_type = "rocksdb"; + } + std::set surviving; + auto remaining = KeyValueDB::list_backups(g_ceph_context, kv_type, backup_path); + if (!remaining) { + return stats; + } + for (const auto& b : *remaining) { + surviving.insert(b.id); + } + std::error_code ec; + for (auto it = std::filesystem::directory_iterator(backup_path, ec); + it != std::filesystem::directory_iterator(); + it.increment(ec)) { + auto name = it->path().filename().string(); + if (name.compare(0, 8, "keyring.") != 0) { + continue; + } + std::string idstr = name.substr(8); + std::string parse_err; + long long id = strict_strtoll(idstr.c_str(), 10, &parse_err); + if (!parse_err.empty() || id < 0) { + continue; + } + if (surviving.count(static_cast(id))) { + continue; + } + std::error_code rm_ec; + std::filesystem::remove(it->path(), rm_ec); + } + return stats; + } + + /// @brief List all backup versions at backup_path. + /// @param cct ceph context + /// @param path path to the local mon data dir (used to discover the kv backend) + /// @param backup_path path to the backup location + /// @return list of BackupStats, one per backup + static std::optional> list_backups( + CephContext *cct, const std::string &path, const std::string &backup_path) { + std::string kv_type; + int r = read_meta_path("kv_backend", &kv_type, path); + if (r < 0 || kv_type.empty()) { + // Disaster recovery: mon_data may be empty or absent. We only ship + // a rocksdb kv backend today, so default to it for enumeration. + kv_type = "rocksdb"; + } + return KeyValueDB::list_backups(cct, kv_type, backup_path); + } + + + /// @brief Restore the backup with the given version from backup_path into path. + /// @param cct ceph context + /// @param path path to the local mon data dir to restore into + /// @param backup_path path to the backup location + /// @param version version of the backup to restore (nullopt for latest) + /// @return true on success + static bool restore_backup(CephContext *cct, const std::string &path, + const std::string &backup_path, + const std::optional &version) { + std::string kv_type; + int r = read_meta_path("kv_backend", &kv_type, path); + if (r < 0 || kv_type.empty()) { + // Disaster recovery: mon_data is empty or freshly initialized, so + // there is no kv_backend marker. Default to rocksdb and stamp the + // file back so the subsequent open() finds it. + kv_type = "rocksdb"; + std::error_code ec; + std::filesystem::create_directories(path, ec); + if (ec) { + lderr(cct) << __func__ << " failed to create " << path + << ": " << ec.message() << dendl; + return false; + } + std::filesystem::permissions(path, + std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, ec); + const std::string v = kv_type + "\n"; + if (safe_write_file(path.c_str(), "kv_backend", + v.c_str(), v.length(), 0600) < 0) { + lderr(cct) << __func__ << " failed to write kv_backend in " + << path << dendl; + return false; + } + } + std::string store_path = get_store_path(path); + + // Resolve "latest" up front so we know which versioned keyring to + // rehydrate alongside the rocksdb restore. Pick by BackupEngine id + // (monotonic per rocksdb) rather than timestamp, so a clock skew + // between backups cannot make the default restore pick a stale one. + uint32_t resolved_version; + if (version) { + resolved_version = *version; + } else { + auto backups = KeyValueDB::list_backups(cct, kv_type, backup_path); + if (!backups || backups->empty()) { + lderr(cct) << __func__ << " no backups found at " << backup_path << dendl; + return false; + } + resolved_version = std::max_element( + backups->begin(), backups->end(), + [](const auto& a, const auto& b) { return a.id < b.id; })->id; + } + + if (!KeyValueDB::restore_backup(cct, kv_type, store_path, backup_path, + resolved_version)) { + return false; + } + + // Rehydrate the matching keyring (skipped silently if the operator + // keeps the keyring out-of-band). + std::error_code ec; + auto keyring_src = backup_path + "/keyring." + std::to_string(resolved_version); + if (std::filesystem::exists(keyring_src, ec)) { + std::filesystem::copy_file( + keyring_src, + path + "/keyring", + std::filesystem::copy_options::overwrite_existing, + ec); + if (ec) { + lderr(cct) << __func__ << " failed to restore keyring from " + << keyring_src << ": " << ec.message() << dendl; + return false; + } + } + + // The mon store holds auth, config-key and dm-crypt secrets; + // tighten everything we just restored to owner-only. + std::filesystem::permissions(path, + std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, ec); + for (auto it = std::filesystem::recursive_directory_iterator(path, ec); + it != std::filesystem::recursive_directory_iterator(); + it.increment(ec)) { + std::error_code ec_chmod; + auto perms = it->is_directory(ec_chmod) + ? std::filesystem::perms::owner_all + : (std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); + std::filesystem::permissions(it->path(), perms, + std::filesystem::perm_options::replace, ec_chmod); + if (ec_chmod) { + lderr(cct) << __func__ << " failed to chmod " << it->path() + << ": " << ec_chmod.message() << dendl; + } + } + return true; + } + void compact() { db->compact(); } @@ -788,7 +983,7 @@ class MonitorDBStore /** * read_meta - read a simple configuration key out-of-band * - * Read a simple key value to an unopened/mounted store. + * Read a simple key value from an unopened/unmounted store. * * Trailing whitespace is stripped off. * @@ -798,6 +993,24 @@ class MonitorDBStore */ int read_meta(const std::string& key, std::string *value) const { + return read_meta_path(key, value, path); + } + + /** + * read_meta_path - read a simple configuration key out-of-band + * + * Read a simple key value from a specified path store. + * + * Trailing whitespace is stripped off. + * + * @param key key name + * @param value pointer to value string + * @param path path to directory + * @returns 0 for success, or an error code + */ + static int read_meta_path(const std::string& key, + std::string *value, + const std::string& path) { char buf[4096]; int r = safe_read_file(path.c_str(), key.c_str(), buf, sizeof(buf)); diff --git a/src/vstart.sh b/src/vstart.sh index b478c00b090..7f5421bffce 100755 --- a/src/vstart.sh +++ b/src/vstart.sh @@ -1157,6 +1157,7 @@ start_mon() { [mon.$f] host = $HOSTNAME mon data = $CEPH_DEV_DIR/mon.$f + mon backup path = $CEPH_DEV_DIR/mon.$f-backup EOF count=$(($count + 2)) done From 83189af8e663d867649d003afaa9d1cf09c88391 Mon Sep 17 00:00:00 2001 From: "Matthew N. Heler" Date: Tue, 16 Jun 2026 13:07:59 -0500 Subject: [PATCH 289/596] rgw/lc: Warn against changing rgw_lc_max_objs once lifecycle is in use Each bucket's lifecycle entry is hashed onto a shard by this count, so changing it remaps existing entries and can leave duplicates behind. Signed-off-by: Matthew N. Heler --- src/common/options/rgw.yaml.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index d9111ffeb07..1a0df1c7fbc 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -474,7 +474,8 @@ options: level: advanced desc: Number of lifecycle data shards long_desc: Number of RADOS objects to use for storing lifecycle index. This affects - concurrency of lifecycle maintenance, as shards can be processed in parallel. + concurrency of lifecycle maintenance, as shards can be processed in parallel. Do not + change this value after deployment unless `radosgw-admin lc list` is empty. default: 32 services: - rgw From a2f175f677f4e4cd276f8e27d950fe35ae54a8a3 Mon Sep 17 00:00:00 2001 From: Xiubo Li Date: Wed, 17 Jun 2026 09:14:43 +0800 Subject: [PATCH 290/596] common/options: mark client_force_lazyio as not runtime updatable The client_force_lazyio option is currently marked as supporting runtime updates in the configuration schema, but this is misleading. The value is read once during each file open/create and stored in the file flags. There is no config observer registered to handle dynamic updates, and there is no logic to propagate changes to the already opened file handles. This patch adds the NO_RUNTIME flag to the option definition to correctly reflect reality. Fixes: https://tracker.ceph.com/issues/77451 Signed-off-by: Xiubo Li --- PendingReleaseNotes | 6 ++++++ doc/cephfs/lazyio.rst | 2 +- src/common/options/mds-client.yaml.in | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 2e2a19fc25f..b858940b1e2 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -1,3 +1,9 @@ +* CephFS: The ``client_force_lazyio`` configuration option is now correctly marked + as not supporting runtime updates. Previously, the configuration schema indicated + this option could be changed at runtime, but changes had no effect on opened file + handlers in ceph-fuse or libcephfs clients because there is no logic to propagate + changes to open file handles. + * RADOS: PG autoscaler allows overlapping roots. Each root receives a PG target based on its OSDs, with OSDs shared across multiple roots contributing proportionally less to each root's allocation. diff --git a/doc/cephfs/lazyio.rst b/doc/cephfs/lazyio.rst index 01cb2704e5f..cd6064ad415 100644 --- a/doc/cephfs/lazyio.rst +++ b/doc/cephfs/lazyio.rst @@ -14,7 +14,7 @@ Enable LazyIO LazyIO can be enabled in the following ways. - ``client_force_lazyio`` option enables LAZY_IO globally for libcephfs and - ceph-fuse mount. + ceph-fuse mount. This is strictly a startup flag. - ``ceph_lazyio(...)`` and ``ceph_ll_lazyio(...)`` enable LAZY_IO for file handle in libcephfs. diff --git a/src/common/options/mds-client.yaml.in b/src/common/options/mds-client.yaml.in index 2e7ee29bc3b..94d717dfbe9 100644 --- a/src/common/options/mds-client.yaml.in +++ b/src/common/options/mds-client.yaml.in @@ -333,6 +333,8 @@ options: default: false services: - mds_client + flags: + - startup - name: fuse_use_invalidate_cb type: bool level: advanced From bf5080c08cd86fe39976ebd5310506d7059dbfc6 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Thu, 27 Nov 2025 19:40:27 +0530 Subject: [PATCH 291/596] client: add client code to allow snap metadata mutations Fixes: https://tracker.ceph.com/issues/66293 Signed-off-by: Rishabh Dave --- ceph.spec.in | 1 + src/client/Client.cc | 50 +++++++++++++++++++++++++++++++++ src/client/Client.h | 3 ++ src/include/ceph_fs.h | 1 + src/include/cephfs/snap_types.h | 25 +++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 src/include/cephfs/snap_types.h diff --git a/ceph.spec.in b/ceph.spec.in index b0fba106c2c..7bb56a985a8 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -2996,6 +2996,7 @@ fi %{_includedir}/cephfs/dump.h %{_includedir}/cephfs/json.h %{_includedir}/cephfs/keys_and_values.h +%{_includedir}/cephfs/snap_types.h %{_libdir}/libcephfs.so %{_libdir}/libcephfs_proxy.so %{_libdir}/pkgconfig/cephfs.pc diff --git a/src/client/Client.cc b/src/client/Client.cc index 3e0135a024f..3a2041a8753 100644 --- a/src/client/Client.cc +++ b/src/client/Client.cc @@ -14245,6 +14245,56 @@ int Client::rmsnap(const char *relpath, const char *name, const UserPerm& perms, return _rmdir(snapdir.get(), name, perms, check_perms); } +int Client::do_snap_md_op(const char* path, const string& md_key, + const string& md_val, const unsigned int op_flag, + const UserPerm &perms) +{ + if (op_flag != CEPH_SNAP_MD_OP_CREATE && + op_flag != (CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL) && + op_flag != CEPH_SNAP_MD_OP_REMOVE) { + return -EINVAL; + } + + RWRef_t mref_reader(mount_state, CLIENT_MOUNTING); + if (!mref_reader.is_state_satisfied()) + return -ENOTCONN; + + std::scoped_lock l(client_lock); + + walk_dentry_result wdr; + if (int rc = path_walk(cwd, filepath(path), &wdr, perms, {}); rc < 0) { + return rc; + } + + if (wdr.target->snapid == CEPH_NOSNAP) { + return -EINVAL; + } + + MetaRequest *req = new MetaRequest(CEPH_MDS_OP_SNAP_METADATA); + req->set_filepath(wdr.getpath()); + req->set_inode(wdr.diri); + req->set_dentry(wdr.dn); + req->dentry_drop = CEPH_CAP_FILE_SHARED; + req->dentry_unless = CEPH_CAP_FILE_EXCL; + + bufferlist bl; + encode(md_key, bl); + encode(md_val, bl); + encode(op_flag, bl); + req->set_data(bl); + + ldout(cct, 10) << __func__ << ": making request" << dendl; + int res = make_request(req, perms, &wdr.target); + ldout(cct, 10) << __func__ << ": result is " << res << dendl; + + trim_cache(); + + ldout(cct, 8) << __func__ << "(" << wdr.getpath() << ", " << perms + << ") = " << res << dendl; + + return res; +} + // ============================= // expose caps diff --git a/src/client/Client.h b/src/client/Client.h index 42459f40a59..99c33fe534c 100644 --- a/src/client/Client.h +++ b/src/client/Client.h @@ -633,6 +633,9 @@ public: int mksnap(const char *path, const char *name, const UserPerm& perm, mode_t mode=0, const std::map &metadata={}); int rmsnap(const char *path, const char *name, const UserPerm& perm, bool check_perms=false); + int do_snap_md_op(const char* path, const std::string& md_key, + const std::string& md_val, const unsigned int op_flag, + const UserPerm &perms); // cephx mds auth caps checking int mds_check_access(std::string& path, const UserPerm& perms, int mask); diff --git a/src/include/ceph_fs.h b/src/include/ceph_fs.h index 9c0b67004cc..b3aca907506 100644 --- a/src/include/ceph_fs.h +++ b/src/include/ceph_fs.h @@ -15,6 +15,7 @@ #include "msgr.h" #include "rados.h" #include "include/buffer.h" // for ceph::buffer::list +#include "include/cephfs/snap_types.h" /* * The data structures defined here are shared between Linux kernel and diff --git a/src/include/cephfs/snap_types.h b/src/include/cephfs/snap_types.h new file mode 100644 index 00000000000..755b3e6f562 --- /dev/null +++ b/src/include/cephfs/snap_types.h @@ -0,0 +1,25 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab + +/* + * snap_types.h - Contains types associated with CephFS snapshots that needs + * to be imported in Cython (Python bindings). Placing these types in ceph_fs.h + * (where it was initially place) causes an Cython import error. + * + * LGPL-2.1 or LGPL-3.0 + */ + + +/* + * snapshot metadata operations + * + * XXX: DON'T FORGET to update cephfs.pyx if any changes are made to this enum. + */ +enum { + // allows both, snap MD create and update + CEPH_SNAP_MD_OP_CREATE = (1 << 0), + // when passed with CREATE, allows only snap MD create and rejects + // update + CEPH_SNAP_MD_OP_EXCL = (1 << 1), + CEPH_SNAP_MD_OP_REMOVE = (1 << 2), +}; From 2aed10be29fa7736051f03c66493d99606fe61e1 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Sat, 14 Mar 2026 16:07:00 +0530 Subject: [PATCH 292/596] mds: enable logging for snap.cc Signed-off-by: Rishabh Dave --- src/mds/snap.cc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/mds/snap.cc b/src/mds/snap.cc index 5e594e2d7ca..9fa77bcf997 100644 --- a/src/mds/snap.cc +++ b/src/mds/snap.cc @@ -15,10 +15,16 @@ #include "snap.h" #include "common/Formatter.h" +#include "common/debug.h" #include #include +#define dout_context g_ceph_context +#define dout_subsys ceph_subsys_mds +#undef dout_prefix +#define dout_prefix *_dout << "mds.snap " + using namespace std; /* * SnapInfo From 047f41aac142bd70d07e25affed7d99cbd96320c Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 2 Mar 2026 21:46:32 +0530 Subject: [PATCH 293/596] mds: rename a finisher method in Server.cc since... it will be used in a different context as well in the upcoming commit. Signed-off-by: Rishabh Dave --- src/mds/Server.cc | 14 ++++++++------ src/mds/Server.h | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/mds/Server.cc b/src/mds/Server.cc index 688c21d3364..12636a0e363 100644 --- a/src/mds/Server.cc +++ b/src/mds/Server.cc @@ -11778,13 +11778,13 @@ void Server::_rmsnap_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snap diri->purge_stale_snap_data(diri->snaprealm->get_snaps()); } -struct C_MDS_renamesnap_finish : public ServerLogContext { +struct C_MDS_SnapMutateGeneric_finish : public ServerLogContext { CInode *diri; snapid_t snapid; - C_MDS_renamesnap_finish(Server *s, const MDRequestRef& r, CInode *di, snapid_t sn) : + C_MDS_SnapMutateGeneric_finish(Server *s, const MDRequestRef& r, CInode *di, snapid_t sn) : ServerLogContext(s, r), diri(di), snapid(sn) {} void finish(int r) override { - server->_renamesnap_finish(mdr, diri, snapid); + server->_snap_mutate_generic_finish(mdr, diri, snapid); } }; @@ -11890,14 +11890,16 @@ void Server::handle_client_renamesnap(const MDRequestRef& mdr) mdcache->journal_dirty_inode(mdr.get(), &le->metablob, diri); // journal the snaprealm changes - submit_mdlog_entry(le, new C_MDS_renamesnap_finish(this, mdr, diri, snapid), + submit_mdlog_entry(le, new C_MDS_SnapMutateGeneric_finish(this, mdr, diri, snapid), mdr, __func__); mdlog->flush(); } -void Server::_renamesnap_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid) +// Finisher method for updating any snapshot metadata, be it custom user metadata +// or snapshot name +void Server::_snap_mutate_generic_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid) { - dout(10) << "_renamesnap_finish " << *mdr << " " << snapid << dendl; + dout(10) << __func__ << " " << *mdr << " " << snapid << dendl; mdr->apply(); diff --git a/src/mds/Server.h b/src/mds/Server.h index b8cfb56452e..e9e9bf4ae25 100644 --- a/src/mds/Server.h +++ b/src/mds/Server.h @@ -347,7 +347,7 @@ public: void handle_client_rmsnap(const MDRequestRef& mdr); void _rmsnap_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid); void handle_client_renamesnap(const MDRequestRef& mdr); - void _renamesnap_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid); + void _snap_mutate_generic_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid); void handle_client_readdir_snapdiff(const MDRequestRef& mdr); void handle_client_file_blockdiff(const MDRequestRef& mdr); void handle_file_blockdiff_finish(const MDRequestRef& mdr, CInode *in, const BlockDiff &block_diff, From a7617f354eb897ce86ba0b45eafa3005c3924d47 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 17 Nov 2025 17:51:48 +0530 Subject: [PATCH 294/596] mds: add MDS code to allow snap metadata mutations Fixes: https://tracker.ceph.com/issues/66293 Signed-off-by: Rishabh Dave --- src/common/ceph_strings.cc | 1 + src/include/ceph_fs.h | 1 + src/mds/Server.cc | 124 +++++++++++++++++++++++++++++++++++++ src/mds/Server.h | 2 + src/mds/SnapRealm.cc | 9 +++ src/mds/SnapRealm.h | 4 ++ src/mds/snap.cc | 63 +++++++++++++++++++ src/mds/snap.h | 6 ++ 8 files changed, 210 insertions(+) diff --git a/src/common/ceph_strings.cc b/src/common/ceph_strings.cc index 01c613b60d4..63437bd2d91 100644 --- a/src/common/ceph_strings.cc +++ b/src/common/ceph_strings.cc @@ -318,6 +318,7 @@ const char *ceph_mds_op_name(int op) case CEPH_MDS_OP_MKSNAP: return "mksnap"; case CEPH_MDS_OP_RMSNAP: return "rmsnap"; case CEPH_MDS_OP_RENAMESNAP: return "renamesnap"; + case CEPH_MDS_OP_SNAP_METADATA: return "snap_md_op"; case CEPH_MDS_OP_READDIR_SNAPDIFF: return "readdir_snapdiff"; case CEPH_MDS_OP_SETFILELOCK: return "setfilelock"; case CEPH_MDS_OP_GETFILELOCK: return "getfilelock"; diff --git a/src/include/ceph_fs.h b/src/include/ceph_fs.h index b3aca907506..d9af11a7389 100644 --- a/src/include/ceph_fs.h +++ b/src/include/ceph_fs.h @@ -429,6 +429,7 @@ enum { CEPH_MDS_OP_RENAMESNAP = 0x01403, CEPH_MDS_OP_READDIR_SNAPDIFF = 0x01404, CEPH_MDS_OP_FILE_BLOCKDIFF = 0x01405, + CEPH_MDS_OP_SNAP_METADATA = 0x1406, // internal op CEPH_MDS_OP_FRAGMENTDIR= 0x01500, diff --git a/src/mds/Server.cc b/src/mds/Server.cc index 12636a0e363..2ad6a617160 100644 --- a/src/mds/Server.cc +++ b/src/mds/Server.cc @@ -268,6 +268,8 @@ void Server::create_logger() "Request type remove snapshot latency"); plb.add_time_avg(l_mdss_req_renamesnap_latency, "req_renamesnap_latency", "Request type rename snapshot latency"); + plb.add_time_avg(l_mdss_req_snap_md_op_latency, "req_snap_md_op_latency", + "Request type snapshot metadata op latency"); plb.add_time_avg(l_mdss_req_snapdiff_latency, "req_snapdiff_latency", "Request type snapshot difference latency"); plb.add_time_avg(l_mdss_req_file_blockdiff_latency, "req_blockdiff_latency", @@ -2273,6 +2275,9 @@ void Server::perf_gather_op_latency(const cref_t &req, utime_t l case CEPH_MDS_OP_RENAMESNAP: code = l_mdss_req_renamesnap_latency; break; + case CEPH_MDS_OP_SNAP_METADATA: + code = l_mdss_req_snap_md_op_latency; + break; case CEPH_MDS_OP_READDIR_SNAPDIFF: code = l_mdss_req_snapdiff_latency; break; @@ -2937,6 +2942,9 @@ void Server::dispatch_client_request(const MDRequestRef& mdr) case CEPH_MDS_OP_RENAMESNAP: handle_client_renamesnap(mdr); break; + case CEPH_MDS_OP_SNAP_METADATA: + handle_client_snap_md_op(mdr); + break; case CEPH_MDS_OP_READDIR_SNAPDIFF: handle_client_readdir_snapdiff(mdr); break; @@ -11919,6 +11927,122 @@ void Server::_snap_mutate_generic_finish(const MDRequestRef& mdr, CInode *diri, respond_to_request(mdr, 0); } +void Server::handle_client_snap_md_op(const MDRequestRef& mdr) +{ + const cref_t &req = mdr->client_request; + + CInode* diri = rdlock_path_pin_ref(mdr, false, true); + if (!diri) + return; + + if (!diri->is_dir()) { + respond_to_request(mdr, -ENOTDIR); + return; + } + + std::string_view snapname = req->get_filepath().last_dentry(); + + if (req->get_caller_uid() < g_conf()->mds_snap_min_uid || + req->get_caller_uid() > g_conf()->mds_snap_max_uid) { + dout(20) << __func__ << " " << snapname << " on " << *diri << + " denied to uid " << req->get_caller_uid() << dendl; + respond_to_request(mdr, -EPERM); + return; + } + + dout(10) << __func__ << " for " << snapname << " on " << *diri << dendl; + // does this snap exist? + if (snapname.length() == 0 || snapname[0] == '_') { + respond_to_request(mdr, -EINVAL); + return; + } + + if (!diri->snaprealm || !diri->snaprealm->exists(snapname)) { + respond_to_request(mdr, -ENOENT); + return; + } + + string md_key; + string md_val; + // setting initial value to an invalid mode to ensure no valid mode is + // assumed by default due to any unexpected errors from any code from the + // following try-catch blocks. the invalid mode would cause + // will_md_op_succeed() to reply negatively, preventing accidental changes + // to the snap MD. + unsigned int op_flag = 3; + if (req->get_data().length()) { + try { + auto iter = req->get_data().cbegin(); + decode(md_key, iter); + decode(md_val, iter); + decode(op_flag, iter); + } catch (const ceph::buffer::error &e) { + dout(20) << __func__ << " : no metadata in payload" << dendl; + respond_to_request(mdr, -EBADMSG); + return; + } + } + + snapid_t snapid = diri->snaprealm->resolve_snapname(snapname, diri->ino()); + dout(10) << __func__ << " snapid " << snapid << dendl; + + // NOTE: check if metadata op will succeed before intiating the transaction or + // projecting the inode so that there is not need to roll back. + bool will_succeed = diri->snaprealm->will_md_op_succeed(snapid, md_key, + md_val, op_flag); + if (!will_succeed) { + dout(10) << __func__ << " will_metadata_op_succeed() failed with md_key=" + << md_key << ", md_val=" << md_val << " and op_flag=" << op_flag + << dendl; + respond_to_request(mdr, -EINVAL); + return; + } + + // get stid + if (!mdr->more()->stid) { + mds->snapclient->prepare_update(diri->ino(), snapid, snapname, utime_t(), + &mdr->more()->stid, + new C_MDS_RetryRequest(mdcache, mdr)); + return; + } + version_t stid = mdr->more()->stid; + dout(10) << __func__ << " stid = " << stid << dendl; + + // project the inode. + auto pi = diri->project_inode(mdr, false, true); + pi.inode->ctime = mdr->get_op_stamp(); + if (mdr->get_op_stamp() > pi.inode->rstat.rctime) + pi.inode->rstat.rctime = mdr->get_op_stamp(); + pi.inode->version = diri->pre_dirty(); + + // update snap md. + auto& snapnode = *(pi.snapnode); + auto it = snapnode.snaps.find(snapid); + if (it == snapnode.snaps.end()) { + respond_to_request(mdr, -ENOENT); + return; + } else { + auto& snapinfo = (*it).second; + snapinfo.stamp = mdr->get_op_stamp(); + snapinfo.do_md_op(md_key, md_val, op_flag); + } + snapnode.last_modified = mdr->get_op_stamp(); + snapnode.change_attr++; + + // journal inode changes. + mdr->ls = mdlog->get_current_segment(); + EUpdate *le = new EUpdate(mdlog, "snap_metadata_op"); + le->metablob.add_client_req(req->get_reqid(), req->get_oldest_client_tid()); + le->metablob.add_table_transaction(TABLE_SNAP, stid); + mdcache->predirty_journal_parents(mdr, &le->metablob, diri, 0, PREDIRTY_PRIMARY, false); + mdcache->journal_dirty_inode(mdr.get(), &le->metablob, diri); + + // journal the snaprealm changes + auto finisher = new C_MDS_SnapMutateGeneric_finish(this, mdr, diri, snapid); + submit_mdlog_entry(le, finisher, mdr, __func__); + mdlog->flush(); +} + class C_MDS_file_blockdiff_finish : public ServerContext { public: C_MDS_file_blockdiff_finish(Server *server, const MDRequestRef& mdr, CInode *in, uint64_t scan_idx) diff --git a/src/mds/Server.h b/src/mds/Server.h index e9e9bf4ae25..016007db7b7 100644 --- a/src/mds/Server.h +++ b/src/mds/Server.h @@ -112,6 +112,7 @@ enum { l_mdss_req_snapdiff_latency, l_mdss_req_rmdir_latency, l_mdss_req_rmsnap_latency, + l_mdss_req_snap_md_op_latency, l_mdss_req_rmxattr_latency, l_mdss_req_setattr_latency, l_mdss_req_setdirlayout_latency, @@ -347,6 +348,7 @@ public: void handle_client_rmsnap(const MDRequestRef& mdr); void _rmsnap_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid); void handle_client_renamesnap(const MDRequestRef& mdr); + void handle_client_snap_md_op(const MDRequestRef& mdr); void _snap_mutate_generic_finish(const MDRequestRef& mdr, CInode *diri, snapid_t snapid); void handle_client_readdir_snapdiff(const MDRequestRef& mdr); void handle_client_file_blockdiff(const MDRequestRef& mdr); diff --git a/src/mds/SnapRealm.cc b/src/mds/SnapRealm.cc index 6eed30da6d9..e9dd2f8bc31 100644 --- a/src/mds/SnapRealm.cc +++ b/src/mds/SnapRealm.cc @@ -21,6 +21,7 @@ #include "MDSRank.h" #include "SnapClient.h" #include "common/debug.h" +#include "include/ceph_assert.h" #include @@ -305,6 +306,14 @@ snapid_t SnapRealm::resolve_snapname(std::string_view n, inodeno_t atino, snapid } +bool SnapRealm::will_md_op_succeed(const snapid_t snap_id, const string& md_key, + const string& md_val, + const unsigned int op_flag) const +{ + return srnode.snaps.at(snap_id).will_md_op_succeed(md_key, md_val, op_flag); +} + + void SnapRealm::adjust_parent() { SnapRealm *newparent; diff --git a/src/mds/SnapRealm.h b/src/mds/SnapRealm.h index e723fb5c3e8..d5eebf5c42f 100644 --- a/src/mds/SnapRealm.h +++ b/src/mds/SnapRealm.h @@ -42,6 +42,10 @@ public: return false; } + bool will_md_op_succeed(const snapid_t snap_id, const std::string& md_key, + const std::string& md_val, + const unsigned int op_flag) const; + void prune_past_parent_snaps(); bool has_past_parent_snaps() const { return !srnode.past_parent_snaps.empty(); diff --git a/src/mds/snap.cc b/src/mds/snap.cc index 9fa77bcf997..89eb5a0b134 100644 --- a/src/mds/snap.cc +++ b/src/mds/snap.cc @@ -71,6 +71,69 @@ void SnapInfo::dump(Formatter *f) const f->close_section(); } +/* Will metadata operation succeed? + * + * NOTE: Call this method before projecting the inode to check if snap metadata + * will be mutated successfully. This avoids un-projecting the inode and rolling + * back the transaction in case the mutation fails. + */ +bool SnapInfo::will_md_op_succeed(const string& key, const string& val, + const unsigned int op_flag) const { + if (op_flag == CEPH_SNAP_MD_OP_CREATE) { + // CREATE flags adds k-v pair if absent and updates if k-v is present. + if (metadata.count(key) == 0) { + dout(10) << __func__ << " snapshot metadata op will pass: create is " + << "being attempted with CREATE flag. op_flag=" << op_flag + << dendl; + } else if (metadata.count(key) > 0) { + dout(10) << __func__ << " snapshot metadata op will pass: update is " + << "being attempted with CREATE flag. op_flag=" << op_flag + << dendl; + } + } else if (op_flag == (CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)) { + if (metadata.count(key) > 0) { + dout(10) << __func__ << " snapshot metadata op will fail: update would " + << "be attempted in with EXCL flag. op_flag=" << op_flag + << dendl; + return false; + } + } else if (op_flag == CEPH_SNAP_MD_OP_REMOVE) { + if (metadata.count(key) == 0) { + dout(10) << __func__ << " snapshot metadata op will fail: remove would " + << "be attempted for a non-existing key. op_flag=" << op_flag + << dendl; + return false; + } + } else { + // NOTE: in case of wrong mode/op_flag value, client code exits with + // appropriate error number instead of contacting MDS with wrong + // mode/op_flag value. + // NOTE: Aborting on receiving wrong flag (via ceph_assert() or otherwise) + // is to be avoided since a corrupted client can be used to deliberately + // crash MDS by sending wrong op_mode flag. + return false; + } + + return true; +} + +// Perform metadata operation indicated by op_flag. +// +// NOTE: to avoid unprojecting the inode and rolling back the transaction for +// snap metadata mutation in case of failure, call this method only when success +// is guaranteed. whether success is guaranted or not, can be found out by +// will_md_op_succeed(), which is present here as well as in SnapRealm.cc. +void SnapInfo::do_md_op(const string& key, const string& val, + const unsigned int op_flag) { + if (op_flag == CEPH_SNAP_MD_OP_CREATE) { + metadata.insert_or_assign(key, val); + } else if (op_flag == (CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)) { + metadata.emplace(key, val); + } else if (op_flag == CEPH_SNAP_MD_OP_REMOVE) { + metadata.erase(key); + } +} + std::list SnapInfo::generate_test_instances() { std::list ls; diff --git a/src/mds/snap.h b/src/mds/snap.h index fb8ccccc78d..41a52c8ba94 100644 --- a/src/mds/snap.h +++ b/src/mds/snap.h @@ -37,6 +37,12 @@ struct SnapInfo { void encode(ceph::buffer::list &bl) const; void decode(ceph::buffer::list::const_iterator &bl); void dump(ceph::Formatter *f) const; + + bool will_md_op_succeed(const std::string& key, const std::string& val, + const unsigned int op_flag) const; + void do_md_op(const std::string& key, const std::string& val, + const unsigned int op_flag); + static std::list generate_test_instances(); std::string_view get_long_name() const; From 5726edbcf37ed9c3cf7835bdc3447c7a4fecec55 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 17 Nov 2025 17:53:56 +0530 Subject: [PATCH 295/596] libcephfs: provide API to mutate snapshot metadata Fixes: https://tracker.ceph.com/issues/66293 Signed-off-by: Rishabh Dave --- src/include/cephfs/libcephfs.h | 16 ++++++++++++++++ src/libcephfs.cc | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/include/cephfs/libcephfs.h b/src/include/cephfs/libcephfs.h index 12c202ae12e..237cbbec0cd 100644 --- a/src/include/cephfs/libcephfs.h +++ b/src/include/cephfs/libcephfs.h @@ -861,6 +861,22 @@ int ceph_mksnap(struct ceph_mount_info *cmount, const char *path, const char *na */ int ceph_rmsnap(struct ceph_mount_info *cmount, const char *path, const char *name); +/** + * Add, update or remove snapshot metadata. + * + * @param cmount the ceph mount handle to use for making the directory. + * @param path the path of the snapshot. This must be either an absolute + * path or a path relative to CWD. + * @param mds_key key for the key-value pair in snapshot metadata. + * @param mds_val value for the key-value pair in snapshot metadata. + * @param op_flag unsigned integer to indicate whether metadata op is create, + * update or remove. + * @returns 0 on success or a negative return value on error. + */ +int ceph_do_snap_md_op(struct ceph_mount_info* cmount, const char* path, + const char* md_key, const char* md_val, + const unsigned int op_flag); + /** * Create multiple directories at once. * diff --git a/src/libcephfs.cc b/src/libcephfs.cc index 0eeddd7167e..add7e30ad3a 100644 --- a/src/libcephfs.cc +++ b/src/libcephfs.cc @@ -1077,6 +1077,18 @@ extern "C" int ceph_rmsnap(struct ceph_mount_info *cmount, const char *path, con return cmount->get_client()->rmsnap(path, name, cmount->default_perms, true); } +extern "C" int ceph_do_snap_md_op(struct ceph_mount_info* cmount, + const char* path, const char* md_key, + const char* md_val, + const unsigned int op_flag) +{ + if (!cmount->is_mounted()) + return -ENOTCONN; + + return cmount->get_client()->do_snap_md_op(path, md_key, md_val, op_flag, + cmount->default_perms); +} + extern "C" int ceph_mkdirs(struct ceph_mount_info *cmount, const char *path, mode_t mode) { if (!cmount->is_mounted()) From 446bd6af1123884912ffbb743950c99c6b699267 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Wed, 19 Nov 2025 22:42:35 +0530 Subject: [PATCH 296/596] tests/libcephfs: add tests for snap metadata mutations Signed-off-by: Rishabh Dave --- src/test/libcephfs/multiclient.cc | 66 +++++ src/test/libcephfs/test.cc | 426 ++++++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) diff --git a/src/test/libcephfs/multiclient.cc b/src/test/libcephfs/multiclient.cc index a7155853f57..371b82b7bb6 100644 --- a/src/test/libcephfs/multiclient.cc +++ b/src/test/libcephfs/multiclient.cc @@ -16,6 +16,7 @@ #include "gtest/gtest.h" #include "include/compat.h" #include "include/cephfs/libcephfs.h" +#include "include/ceph_fs.h" #include #include #include @@ -179,3 +180,68 @@ TEST(LibCephFS, MulticlientRevokeCaps) { thread1.join(); thread2.join(); } + + +// Test that client #2 can successfully read snap metadata mutation made by +// client #1. +TEST(LibCephFS, SnapMdMutate) { + struct ceph_mount_info *cmount, *cmount2; + + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(ceph_mount(cmount, NULL), 0); + + ASSERT_EQ(ceph_create(&cmount2, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount2, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount2, NULL), 0); + ASSERT_EQ(ceph_mount(cmount2, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-5", getpid()); + sprintf(snap_name, "snap_%d_5", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, + {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test - + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "foo", "bar123", + CEPH_SNAP_MD_OP_CREATE)); + + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount2, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 3); + + // verify snap metadata + struct snap_metadata snap_meta2[] = {{"foo", "bar123"}, {"this", "that"}, + {"abcde", "12345"}}; + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta2); ++j) { + if (k == snap_meta2[j].key and v == snap_meta2[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); + ceph_shutdown(cmount2); +} diff --git a/src/test/libcephfs/test.cc b/src/test/libcephfs/test.cc index 7d3aed44a2b..2347cc45670 100644 --- a/src/test/libcephfs/test.cc +++ b/src/test/libcephfs/test.cc @@ -17,6 +17,7 @@ #include "include/filepath.h" #include "gtest/gtest.h" #include "include/cephfs/libcephfs.h" +#include "include/ceph_fs.h" #include "mds/mdstypes.h" #include "include/stat.h" #include @@ -2799,6 +2800,431 @@ TEST(LibCephFS, SnapInfo) { ceph_shutdown(cmount); } +// Test that ceph_do_snap_md_op() in create mode successfully adds new k-v +// pairs to snapshot metadata. +TEST(LibCephFS, SnapMdCreateAdds) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-2", getpid()); + sprintf(snap_name, "snap_%d_2", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, + {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test. + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "foo2", "bar2", + CEPH_SNAP_MD_OP_CREATE)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 4); + + // verify snap metadata + struct snap_metadata snap_meta2[] = {{"foo", "bar"}, {"foo2", "bar2"}, + {"this", "that"}, {"abcde", "12345"}}; + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta2); ++j) { + if (k == snap_meta2[j].key and v == snap_meta2[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ceph_free_snap_info_buffer(&info); + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + + +// Test that ceph_do_snap_md_op() in create mode successfully updates new k-v +// pairs to snapshot metadata. +TEST(LibCephFS, SnapMdCreateUpdates) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-2", getpid()); + sprintf(snap_name, "snap_%d_2", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, + {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test. + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "foo", "bar2", + CEPH_SNAP_MD_OP_CREATE)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 3); + + // verify snap metadata + struct snap_metadata snap_meta2[] = {{"foo", "bar2"}, {"this", "that"}, + {"abcde", "12345"}}; + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta2); ++j) { + if (k == snap_meta2[j].key and v == snap_meta2[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ceph_free_snap_info_buffer(&info); + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + + +// Test that k-v pair is added successfully by ceph_do_snap_md_op() in +// EXCL mode. +TEST(LibCephFS, SnapMdExclAdds) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-1", getpid()); + sprintf(snap_name, "snap_%d_1", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test. + ASSERT_EQ(0, + ceph_do_snap_md_op(cmount, snap_path, "foo2", "bar2", + CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 4); + + // verify snap metadata + struct snap_metadata snap_meta2[] = {{"foo", "bar"}, {"foo2", "bar2"}, + {"this", "that"}, {"abcde", "12345"}}; + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta2); ++j) { + if (k == snap_meta2[j].key and v == snap_meta2[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ceph_free_snap_info_buffer(&info); + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + + +// Test that k-v pair update is rejected successfully by ceph_do_snap_md_op() in +// EXCL mode. +TEST(LibCephFS, SnapMdExclRejectsUpdate) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-1", getpid()); + sprintf(snap_name, "snap_%d_1", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test. + ASSERT_EQ(-EINVAL, + ceph_do_snap_md_op(cmount, snap_path, "foo", "bar2", + CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 3); + + // verify snap metadata + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta); ++j) { + if (k == snap_meta[j].key and v == snap_meta[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ceph_free_snap_info_buffer(&info); + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + + +// Test that ceph_do_snap_md_op() in remove mode successfully removes k-v +// pairs from snapshot metadata. +TEST(LibCephFS, SnapMdRemove) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-3", getpid()); + sprintf(snap_name, "snap_%d_3", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, + {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test. + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "foo", "bar", + CEPH_SNAP_MD_OP_REMOVE)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 2); + + // verify snap metadata + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + assert(k != "foo"); + assert(v != "bar"); + + bool found = false; + for (size_t j = 1; j < std::size(snap_meta); ++j) { + if (k == snap_meta[j].key and v == snap_meta[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + +// Test all snap MD operations with empty strings. +TEST(LibCephFS, SnapMdOpEmptyStrings) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-4", getpid()); + sprintf(snap_name, "snap_%d_4", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual testing begins... + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "", "", + CEPH_SNAP_MD_OP_CREATE)); + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "", "1", + CEPH_SNAP_MD_OP_CREATE)); + + ASSERT_EQ(0, + ceph_do_snap_md_op(cmount, snap_path, "1", "", + CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)); + + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "", "", + CEPH_SNAP_MD_OP_REMOVE)); + ASSERT_EQ(0, ceph_do_snap_md_op(cmount, snap_path, "1", "", + CEPH_SNAP_MD_OP_REMOVE)); + + // get latest snap metadata + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 3); + + // verify snap metadata + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta); ++j) { + if (k == snap_meta[j].key and v == snap_meta[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + +// Test that ceph_do_snap_md_op() disallows inapproriate ops depending on +// mode. +TEST(LibCephFS, SnapMdNegTest) { + struct ceph_mount_info *cmount; + ASSERT_EQ(ceph_create(&cmount, NULL), 0); + ASSERT_EQ(ceph_conf_read_file(cmount, NULL), 0); + ASSERT_EQ(ceph_conf_parse_env(cmount, NULL), 0); + ASSERT_EQ(do_ceph_mount(cmount, NULL), 0); + + char dir_path[64]; + char snap_name[64]; + char snap_path[PATH_MAX]; + sprintf(dir_path, "/dir0_%d-5", getpid()); + sprintf(snap_name, "snap_%d_5", getpid()); + sprintf(snap_path, "%s/.snap/%s", dir_path, snap_name); + + ASSERT_EQ(0, ceph_mkdir(cmount, dir_path, 0755)); + // snapshot with custom metadata + struct snap_metadata snap_meta[] = {{"foo", "bar"}, + {"this", "that"}, + {"abcde", "12345"}}; + ASSERT_EQ(0, ceph_mksnap(cmount, dir_path, snap_name, 0755, snap_meta, + std::size(snap_meta))); + + // actual test - + // attempt create in EXCL mode. + ASSERT_EQ(-EINVAL, + ceph_do_snap_md_op(cmount, snap_path, "foo", "bar2", + CEPH_SNAP_MD_OP_CREATE | CEPH_SNAP_MD_OP_EXCL)); + // attempt remove for an unexisting key. + ASSERT_EQ(-EINVAL, ceph_do_snap_md_op(cmount, snap_path, "foo2", "bar2", + CEPH_SNAP_MD_OP_REMOVE)); + // run ceph_do_snap_md_op() in invalid mode/with invalid op_flag. + ASSERT_EQ(-EINVAL, ceph_do_snap_md_op(cmount, snap_path, "foo123", "bar123", + 5)); + + struct snap_info info; + ASSERT_EQ(0, ceph_get_snap_info(cmount, snap_path, &info)); + ASSERT_GT(info.id, 1); + ASSERT_EQ(info.nr_snap_metadata, 3); + + // verify snap metadata + for (size_t i = 0; i < info.nr_snap_metadata; ++i) { + auto k = std::string(info.snap_metadata[i].key); + auto v = std::string(info.snap_metadata[i].value); + + bool found = false; + for (size_t j = 0; j < std::size(snap_meta); ++j) { + if (k == snap_meta[j].key and v == snap_meta[j].value) { + found = true; + break; + } + } + + ASSERT_EQ(found, true); + } + + // teardown + ASSERT_EQ(0, ceph_rmsnap(cmount, dir_path, snap_name)); + ASSERT_EQ(0, ceph_rmdir(cmount, dir_path)); + ceph_shutdown(cmount); +} + TEST(LibCephFS, LookupInoMDSDir) { struct ceph_mount_info *cmount; ASSERT_EQ(ceph_create(&cmount, NULL), 0); From 8d5cf70ba6affe5fcb921509befc59c990c5a3bf Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Wed, 1 Apr 2026 18:01:56 +0530 Subject: [PATCH 297/596] pybind/cephfs: add python binding for ceph_do_snap_md_op() Signed-off-by: Rishabh Dave --- src/pybind/cephfs/c_cephfs.pxd | 5 ++++ src/pybind/cephfs/cephfs.pyx | 49 +++++++++++++++++++++++++++++++ src/pybind/cephfs/mock_cephfs.pxi | 6 ++++ 3 files changed, 60 insertions(+) diff --git a/src/pybind/cephfs/c_cephfs.pxd b/src/pybind/cephfs/c_cephfs.pxd index 9d5b93c0493..56f1d7d1899 100644 --- a/src/pybind/cephfs/c_cephfs.pxd +++ b/src/pybind/cephfs/c_cephfs.pxd @@ -148,10 +148,15 @@ cdef extern from "cephfs/libcephfs.h" nogil: int ceph_mkdir(ceph_mount_info *cmount, const char *path, mode_t mode) int ceph_mkdirat(ceph_mount_info *cmount, int dirfd, const char *relpath, mode_t mode) + int ceph_mksnap(ceph_mount_info *cmount, const char *path, const char *name, mode_t mode, snap_metadata *snap_metadata, size_t nr_snap_metadata) int ceph_rmsnap(ceph_mount_info *cmount, const char *path, const char *name) int ceph_get_snap_info(ceph_mount_info *cmount, const char *path, snap_info *snap_info) void ceph_free_snap_info_buffer(snap_info *snap_info) + int ceph_do_snap_md_op(ceph_mount_info *cmount, const char *path, + const char *md_key, const char *md_val, + const unsigned int op_flag) + int ceph_mkdirs(ceph_mount_info *cmount, const char *path, mode_t mode) int ceph_closedir(ceph_mount_info *cmount, ceph_dir_result *dirp) int ceph_opendir(ceph_mount_info *cmount, const char *name, ceph_dir_result **dirpp) diff --git a/src/pybind/cephfs/cephfs.pyx b/src/pybind/cephfs/cephfs.pyx index 650beacb975..6e0264fc17c 100644 --- a/src/pybind/cephfs/cephfs.pyx +++ b/src/pybind/cephfs/cephfs.pyx @@ -72,6 +72,20 @@ CEPH_SETATTR_BTIME = 0x200 CEPH_NOSNAP = -2 + +# XXX: DON'T FORGET to update values in src/include/cephfs/snap_types.h, if you +# make any changes below. +# +# NOTE: Regretfully re-defined here to avoid all the compiler/path black magic +# required to pass the "docs" CI job. Compiling via 'cdef extern' works locally +# but fails in the isolated ReadTheDocs build environment due to missing directory +# hierarchies. +cpdef enum: + CEPH_SNAP_MD_OP_CREATE = (1 << 0) + CEPH_SNAP_MD_OP_EXCL = (1 << 1) + CEPH_SNAP_MD_OP_REMOVE = (1 << 2) + + # XXX: errno definitions, hard-coded numbers here are errnos defined by Linux # that are used for the Ceph on-the-wire status codes. EBLOCKLISTED = ceph_to_hostos_errno(108) @@ -1255,6 +1269,41 @@ cdef class LibCephFS(object): ceph_free_snap_info_buffer(&info) return {'id': info.id, 'metadata': md} + def do_snap_md_op(self, snap_path, md_key, md_val, op_flag): + ''' + Create, update or remove a key-value pair from snapshot metadata. + + :param snap_path: snapshot path + :param md_key: key for the key-value pair + :param md_val: value for the key-value pair + :param op_flag: flag indicating the op to be performed. it can be + create, update or remove. + + :raises: :class: `TypeError` + :raises: :class: `Error` + + :returns: 0 on success + ''' + self.require_state('mounted') + + snap_path = cstr(snap_path, 'snap_path') + md_key = cstr(md_key, 'md_key') + md_val = cstr(md_val, 'md_val') + + if not isinstance(op_flag, int): + raise TypeError('"op_flag" must be an int') + + cdef: + char* _snap_path = snap_path + char* _md_key = md_key + char* _md_val = md_val + int _op_flag = op_flag + + with nogil: + ret = ceph_do_snap_md_op(self.cluster, _snap_path, _md_key, _md_val, _op_flag) + if ret < 0: + raise make_ex(ret, 'snap_md_op') + def chmod(self, path, mode) -> None: """ Change directory mode. diff --git a/src/pybind/cephfs/mock_cephfs.pxi b/src/pybind/cephfs/mock_cephfs.pxi index 9c2b0f2e0bd..bd3e54e040e 100644 --- a/src/pybind/cephfs/mock_cephfs.pxi +++ b/src/pybind/cephfs/mock_cephfs.pxi @@ -207,6 +207,7 @@ cdef nogil: pass int ceph_mkdirat(ceph_mount_info *cmount, int dirfd, const char *relpath, mode_t mode): pass + int ceph_mksnap(ceph_mount_info *cmount, const char *path, const char *name, mode_t mode, snap_metadata *snap_metadata, size_t nr_snap_metadata): pass int ceph_rmsnap(ceph_mount_info *cmount, const char *path, const char *name): @@ -215,6 +216,11 @@ cdef nogil: pass void ceph_free_snap_info_buffer(snap_info *snap_info): pass + int ceph_do_snap_md_op(ceph_mount_info* cmount, const char* path, + const char* md_key, const char* md_val, + const unsigned int op_flag): + pass + int ceph_mkdirs(ceph_mount_info *cmount, const char *path, mode_t mode): pass int ceph_closedir(ceph_mount_info *cmount, ceph_dir_result *dirp): From df7abb7a7b7be952e30e2ac286b108c3cab5ca90 Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Wed, 1 Apr 2026 18:02:28 +0530 Subject: [PATCH 298/596] test_cephfs.py: add tests for do_snap_md_op() Signed-off-by: Rishabh Dave --- src/test/pybind/test_cephfs.py | 227 +++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/src/test/pybind/test_cephfs.py b/src/test/pybind/test_cephfs.py index 89db36fa8a7..72ed8097930 100644 --- a/src/test/pybind/test_cephfs.py +++ b/src/test/pybind/test_cephfs.py @@ -3243,3 +3243,230 @@ class TestFcopyfile: mode. ''' self._write_file_and_test_fcopyfile(testdir, int(1 * 1000 * 1000 * 1000)) + + +class TestDoSnapMdOp: + ''' + Contains tests for do_snap_md_op(). + ''' + + def test_create_allows_adding(self, testdir): + ''' + Test that do_snap_md_op() allows adding new key-value pairs to snapshot + metadata in create mode. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + cephfs.do_snap_md_op(snap_path, 'foo2', 'bar2', + libcephfs.CEPH_SNAP_MD_OP_CREATE) + # update metadata at our end too. + md['foo2'] = 'bar2' + + # verify that "create" was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['foo2'], md['foo2']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_create_allows_updating(self, testdir): + ''' + Test that do_snap_md_op() allows updating new key-value pairs to snapshot + metadata in create mode. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + cephfs.do_snap_md_op(snap_path, 'foo', 'bar123', + libcephfs.CEPH_SNAP_MD_OP_CREATE) + # update metadata at our end too. + md['foo'] = 'bar123' + + # verify that "create" was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_excl_allows_adding(self, testdir): + ''' + Test that do_snap_md_op() allows adding key-value pair in snapshot + metadata in EXCL mode. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + cephfs.do_snap_md_op(snap_path, 'foo2', 'bar2', + libcephfs.CEPH_SNAP_MD_OP_CREATE | \ + libcephfs.CEPH_SNAP_MD_OP_EXCL) + # update metadata at our end too. + md['foo2'] = 'bar2' + + # verify that update was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_excl_disallows_updating(self, testdir): + ''' + Test that do_snap_md_op() rejects updating key-value pair in snapshot + metadata in EXCL mode. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + assert_raises(libcephfs.InvalidValue, cephfs.do_snap_md_op, snap_path, + 'foo', 'bar123', libcephfs.CEPH_SNAP_MD_OP_CREATE | \ + libcephfs.CEPH_SNAP_MD_OP_EXCL) + + # verify that update was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_remove(self, testdir): + ''' + Test that do_snap_md_op() allows removing key-value pairs from snapshot + metadata. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'foo2': 'bar2', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + cephfs.do_snap_md_op(snap_path, 'foo2', 'bar2', + libcephfs.CEPH_SNAP_MD_OP_REMOVE) + # update metadata at our end too. + md.pop('foo2') + + # verify that "create" was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + try: + snap_info['metadata']['foo2'] + except KeyError: + pass + else: + raise RuntimeError('Key "foo2" must\'ve been absent in snapshot ' + 'metadata') + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_with_empty_strings(self, testdir): + ''' + Test all snap MD ops with empty string + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + # test adding k-v pair via CREATE with empty strings + cephfs.do_snap_md_op(snap_path, '', '', + libcephfs.CEPH_SNAP_MD_OP_CREATE) + cephfs.do_snap_md_op(snap_path, '', '1', + libcephfs.CEPH_SNAP_MD_OP_CREATE) + + # test adding k-v pair via EXCL with empty strings + cephfs.do_snap_md_op(snap_path, '1', '', + libcephfs.CEPH_SNAP_MD_OP_CREATE | \ + libcephfs.CEPH_SNAP_MD_OP_EXCL) + + # test remove k-v with empty strings + cephfs.do_snap_md_op(snap_path, '', '', + libcephfs.CEPH_SNAP_MD_OP_REMOVE) + cephfs.do_snap_md_op(snap_path, '1', '', + libcephfs.CEPH_SNAP_MD_OP_REMOVE) + + # verify that "create" was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) + + def test_neg(self, testdir): + ''' + Test that do_snap_md_op() errors out when snapshot metadata operation + fails. + ''' + dir_path = '/dir1' + snap_name = 'snap0' + snap_path = os.path.join(dir_path, '.snap', snap_name) + + cephfs.mkdir(dir_path, 0o755) + md = {'foo': 'bar', 'zig': 'zag', 'abcdefg': '12345'} + cephfs.mksnap(dir_path, snap_name, 0o755, metadata=md) + + # actual testing begins + # test updating k-v pair in EXCL mode + assert_raises(libcephfs.InvalidValue, cephfs.do_snap_md_op, snap_path, + 'foo', 'bar2', libcephfs.CEPH_SNAP_MD_OP_CREATE | \ + libcephfs.CEPH_SNAP_MD_OP_EXCL) + # test remove non-existing k-v pair + assert_raises(libcephfs.InvalidValue, cephfs.do_snap_md_op, snap_path, + 'foo2', 'bar2', libcephfs.CEPH_SNAP_MD_OP_REMOVE) + # test with invalid op_flag + assert_raises(libcephfs.InvalidValue, cephfs.do_snap_md_op, snap_path, + 'foo2', 'bar2', 5) + + # verify that "create" was successful + snap_info = cephfs.snap_info(snap_path) + assert_equal(snap_info['metadata']['foo'], md['foo']) + assert_equal(snap_info['metadata']['zig'], md['zig']) + assert_equal(snap_info['metadata']['abcdefg'], md['abcdefg']) + assert_greater(snap_info['id'], 1) + + cephfs.rmsnap(dir_path, snap_name) + cephfs.rmdir(dir_path) From 7f1f0e9eb2fabb88a8e44525cb8490a44a95a79d Mon Sep 17 00:00:00 2001 From: Rishabh Dave Date: Mon, 15 Jun 2026 16:39:25 +0530 Subject: [PATCH 299/596] PendingReleaseNotes: add note for mutability of CephFS snapshot metadata Signed-off-by: Rishabh Dave --- PendingReleaseNotes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 2e2a19fc25f..edb30eea8b0 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -2,6 +2,10 @@ on its OSDs, with OSDs shared across multiple roots contributing proportionally less to each root's allocation. +* CephFS: CephFS snapshots metadata is now mutable. It is now possible to add, + update and remove existing key-value pairs that are part of a snapshot + metadata via libcephfs API ceph_do_snap_md_op(). + * librados/neorados: The C++ APIs for executing Ceph Class (CLS) methods have undergone a breaking change to enforce compile-time type safety, replacing legacy string-based parameters with strongly-typed ClsMethod structs. C++ From 955d7e68ea548c29cb9267894b7221e61f93af17 Mon Sep 17 00:00:00 2001 From: Vallari Agrawal Date: Tue, 16 Jun 2026 20:08:29 +0530 Subject: [PATCH 300/596] mgr/dashboard: Add "gw refresh_network" cmd Fixes: https://tracker.ceph.com/issues/77442 Signed-off-by: Vallari Agrawal --- .../mgr/dashboard/controllers/nvmeof.py | 37 ++++++++++++ src/pybind/mgr/dashboard/model/nvmeof.py | 7 +++ src/pybind/mgr/dashboard/openapi.yaml | 57 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 557ea546ef1..95065f361bd 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -330,6 +330,43 @@ else: NVMeoFClient.pb2.get_thread_stats_req() ) + @UpdatePermission + @Endpoint('PUT', '/refresh_network') + @NvmeofCLICommand( + "nvmeof gateway refresh_network", model.GwRefreshNetworkStatus, + alias="nvmeof gw refresh_network", + success_message_template=("Refreshed configured network masks for subsystem " + "{nqn} on this gateway: Successful{added}{removed}"), + success_message_map={ + "added": lambda v, _f: f"\nAdded: {', '.join(v)}" if v else "", + "removed": lambda v, _f: f"\nRemoved: {', '.join(v)}" if v else "", + } + ) + @EndpointDoc( + "Re-evaluate subsystem network masks and update auto-listeners for this gateway", + parameters={ + "nqn": Param(str, "NVMeoF subsystem NQN"), + "gw_group": Param(str, "NVMeoF gateway group", True, None), + "server_address": Param(str, "NVMeoF gateway address", True, None), + "traddr": Param(str, "NVMeoF gateway address (deprecated)", True, None), + }, + ) + @convert_to_model(model.GwRefreshNetworkStatus) + @handle_nvmeof_error + def refresh_network(self, nqn: str, gw_group: Optional[str] = None, + server_address: Optional[str] = None, + traddr: Optional[str] = None): + server_address = resolve_nvmeof_server_address( + server_address=server_address, + traddr=traddr + ) + return NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.gw_refresh_network( + NVMeoFClient.pb2.gw_refresh_network_req(subsystem_nqn=nqn) + ) + @APIRouter("/nvmeof/spdk", Scope.NVME_OF) @APIDoc("NVMe-oF SPDK Management API", "NVMe-oF SPDK") class NVMeoFSpdk(RESTController): diff --git a/src/pybind/mgr/dashboard/model/nvmeof.py b/src/pybind/mgr/dashboard/model/nvmeof.py index f4b7327514e..ec703740042 100644 --- a/src/pybind/mgr/dashboard/model/nvmeof.py +++ b/src/pybind/mgr/dashboard/model/nvmeof.py @@ -369,6 +369,13 @@ class RequestStatus(NamedTuple): error_message: str +class GwRefreshNetworkStatus(NamedTuple): + status: int + error_message: str + added: List[str] + removed: List[str] + + class ListenAdress(NamedTuple): trtype: str adrfam: str diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 2ddd1190b47..1bbd2efbee7 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -12916,6 +12916,63 @@ paths: summary: Set NVMeoF gateway log levels tags: - NVMe-oF Gateway + /api/nvmeof/gateway/refresh_network: + put: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + gw_group: + description: NVMeoF gateway group + type: string + nqn: + description: NVMeoF subsystem NQN + type: string + server_address: + description: NVMeoF gateway address + type: string + traddr: + description: NVMeoF gateway address (deprecated) + type: string + required: + - nqn + type: object + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource updated. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Re-evaluate subsystem network masks and update auto-listeners for this + gateway + tags: + - NVMe-oF Gateway /api/nvmeof/gateway/stats: get: parameters: From 268a8578032498dde6caaacf1edd1b9700bfa2cd Mon Sep 17 00:00:00 2001 From: Naman Munet Date: Thu, 18 Jun 2026 13:34:55 +0530 Subject: [PATCH 301/596] mgr/dashboard: fix daemon e2e fixes: https://tracker.ceph.com/issues/77489 Signed-off-by: Naman Munet --- .../mgr/dashboard/frontend/cypress/e2e/rgw/daemons.po.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/daemons.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/daemons.po.ts index 6fabffd9e2e..258b21bf466 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/daemons.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/daemons.po.ts @@ -25,8 +25,11 @@ export class DaemonsPageHelper extends PageHelper { // click on performance counters tab and check table is loaded cy.contains('.nav-link', 'Performance Counters').click(); - // check at least one field is present - this.getTableCell().should('be.visible').should('contain.text', 'objecter.op_r'); + // check at least one field is present (objecter counter may have memory address suffix) + this.getTableCell() + .should('be.visible') + .invoke('text') + .should('match', /objecter.*\.op_r/); // click on performance details tab cy.contains('.nav-link', 'Performance Details').click(); From 3d174f6b40704b42add17e6f96795bf237880fa0 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sat, 13 Jun 2026 18:24:45 +0800 Subject: [PATCH 302/596] crimson/os/seastore/lba: no need to pass the reference to the transaction when synchronously updating the lba mapping Because the transaction can be captured by the functor itself Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/lba/btree_lba_manager.cc | 4 ++-- src/crimson/os/seastore/transaction.h | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/crimson/os/seastore/lba/btree_lba_manager.cc b/src/crimson/os/seastore/lba/btree_lba_manager.cc index 6a42f5ae662..6fa10fcd948 100644 --- a/src/crimson/os/seastore/lba/btree_lba_manager.cc +++ b/src/crimson/os/seastore/lba/btree_lba_manager.cc @@ -1204,8 +1204,8 @@ BtreeLBAManager::_copy_mapping( c.trans.new_lba_key_copied( ret.src->get_key(), dest_laddr, - [this](Transaction &t, laddr_t laddr, paddr_t paddr) { - update_paddr_sync(t, laddr, paddr); + [this, c](laddr_t laddr, paddr_t paddr) { + update_paddr_sync(c.trans, laddr, paddr); }); auto [niter, inserted] = co_await btree.copy( c, diff --git a/src/crimson/os/seastore/transaction.h b/src/crimson/os/seastore/transaction.h index 3c2be49db9a..dc2751e5835 100644 --- a/src/crimson/os/seastore/transaction.h +++ b/src/crimson/os/seastore/transaction.h @@ -671,7 +671,7 @@ public: bool force_rewrite_conflict = false; using update_copied_lba_key_func_t = - std::function; + std::function; void new_lba_key_copied( laddr_t src, laddr_t dest, @@ -691,7 +691,7 @@ public: return; } laddr_t key = it->second; - update_copied_lba_key(*this, key, paddr); + update_copied_lba_key(key, paddr); } RootBlockRef peek_root() { return root; @@ -923,7 +923,7 @@ private: cache_hint_t cache_hint = CACHE_HINT_TOUCH; std::map copied_lba_keys; - std::function update_copied_lba_key; + update_copied_lba_key_func_t update_copied_lba_key; }; using TransactionRef = Transaction::Ref; From 5d52c075903dc0792ce97e20985d299c964aad40 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sat, 13 Jun 2026 18:25:51 +0800 Subject: [PATCH 303/596] crimson/os/seastore/lba: don't update the lba mapping if its child is an initial pending one. Because initial pending extents' paddrs are considered newer. Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/lba/btree_lba_manager.cc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/crimson/os/seastore/lba/btree_lba_manager.cc b/src/crimson/os/seastore/lba/btree_lba_manager.cc index 6fa10fcd948..1558802eb71 100644 --- a/src/crimson/os/seastore/lba/btree_lba_manager.cc +++ b/src/crimson/os/seastore/lba/btree_lba_manager.cc @@ -1154,6 +1154,14 @@ void BtreeLBAManager::update_paddr_sync( auto btree = get_btree_sync(c); auto iter = btree.lower_bound_sync(c, laddr); assert(iter.get_leaf_node()->is_pending()); + auto child = iter.get_leaf_node()->get_child_sync( + c.trans, c.cache, iter.get_leaf_pos(), iter.get_key()); + ceph_assert(child); + if (child->is_initial_pending()) { + TRACET("{} is initial_pending, skipping", t, *child); + return; + } + ceph_assert(child->is_exist_clean()); auto cursor = iter.get_cursor(c); assert(cursor->get_laddr() == laddr); btree.update( From f08533c4877d30462d1c86e89486d7f6be9dced9 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Mon, 15 Jun 2026 10:43:36 +0800 Subject: [PATCH 304/596] crimson/os/seastore/lba: don't update LBALeafNode::modifications when updating the lba mapping for committing rewrite transactions Signed-off-by: Xuehan Xu --- .../os/seastore/backref/backref_tree_node.h | 3 ++- .../os/seastore/btree/fixed_kv_btree.h | 19 +++++++++++++++++-- src/crimson/os/seastore/btree/fixed_kv_node.h | 3 ++- .../os/seastore/lba/btree_lba_manager.cc | 3 ++- src/crimson/os/seastore/lba/lba_btree_node.cc | 7 +++++-- src/crimson/os/seastore/lba/lba_btree_node.h | 3 ++- 6 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/crimson/os/seastore/backref/backref_tree_node.h b/src/crimson/os/seastore/backref/backref_tree_node.h index d8d5961a9ad..8af8afaebe0 100644 --- a/src/crimson/os/seastore/backref/backref_tree_node.h +++ b/src/crimson/os/seastore/backref/backref_tree_node.h @@ -105,7 +105,8 @@ public: void update( const_iterator iter, - backref_map_val_t val) final { + backref_map_val_t val, + modification_t) final { return journal_update( iter, val, diff --git a/src/crimson/os/seastore/btree/fixed_kv_btree.h b/src/crimson/os/seastore/btree/fixed_kv_btree.h index b701062610a..fdfa31230ab 100644 --- a/src/crimson/os/seastore/btree/fixed_kv_btree.h +++ b/src/crimson/os/seastore/btree/fixed_kv_btree.h @@ -20,6 +20,16 @@ namespace crimson::os::seastore { +enum modification_t { + USER_MODIFY, // logical update (affects node state) + TRANS_SYNC // synchronization-only update (should NOT count + // as modification and should avoid on_modify(), + // for example, BtreeLBAManager::update_paddr_sync + // does not change the logical mapping, only the + // physical location (paddr) of the same data, so + // TRANS_SYNC is used. +}; + template phy_tree_root_t& get_phy_tree_root(root_t& r); @@ -1108,7 +1118,8 @@ public: op_context_t c, iterator iter, node_val_t val, - BaseChildNode *child) + BaseChildNode *child, + modification_t mod = modification_t::USER_MODIFY) { LOG_PREFIX(FixedKVBtree::update); SUBTRACET( @@ -1116,6 +1127,9 @@ public: "update element at {}", c.trans, iter.is_end() ? min_max_t::max : iter.get_key()); + if (mod == modification_t::TRANS_SYNC) { + assert(child == nullptr); + } if (!iter.leaf.node->is_mutable()) { CachedExtentRef mut = c.cache.duplicate_for_write( c.trans, iter.leaf.node @@ -1125,7 +1139,8 @@ public: ++(get_tree_stats(c.trans).num_updates); iter.leaf.node->update( iter.leaf.node->iter_idx(iter.leaf.pos), - val); + val, + mod); if constexpr (std::is_base_of_v< ParentNode, leaf_node_t>) { if (child) { diff --git a/src/crimson/os/seastore/btree/fixed_kv_node.h b/src/crimson/os/seastore/btree/fixed_kv_node.h index 74e5d47af7b..24475157938 100644 --- a/src/crimson/os/seastore/btree/fixed_kv_node.h +++ b/src/crimson/os/seastore/btree/fixed_kv_node.h @@ -729,7 +729,8 @@ struct FixedKVLeafNode virtual void update( internal_const_iterator_t iter, - VAL val) = 0; + VAL val, + modification_t mod) = 0; virtual internal_const_iterator_t insert( internal_const_iterator_t iter, NODE_KEY addr, diff --git a/src/crimson/os/seastore/lba/btree_lba_manager.cc b/src/crimson/os/seastore/lba/btree_lba_manager.cc index 1558802eb71..124aa1a4da2 100644 --- a/src/crimson/os/seastore/lba/btree_lba_manager.cc +++ b/src/crimson/os/seastore/lba/btree_lba_manager.cc @@ -1173,7 +1173,8 @@ void BtreeLBAManager::update_paddr_sync( cursor->get_refcount(), cursor->get_checksum(), cursor->get_extent_type()}, - nullptr); + nullptr, + modification_t::TRANS_SYNC); } BtreeLBAManager::move_mapping_ret diff --git a/src/crimson/os/seastore/lba/lba_btree_node.cc b/src/crimson/os/seastore/lba/lba_btree_node.cc index ed94290f72a..1e819b032e5 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.cc +++ b/src/crimson/os/seastore/lba/lba_btree_node.cc @@ -46,13 +46,16 @@ void LBALeafNode::resolve_relative_addrs(paddr_t base) void LBALeafNode::update( internal_const_iterator_t iter, - lba_map_val_t val) + lba_map_val_t val, + modification_t mod) { LOG_PREFIX(LBALeafNode::update); SUBTRACE(seastore_fixedkv_tree, "trans.{}, pos {}", this->pending_for_transaction, iter.get_offset()); - this->on_modify(); + if (likely(mod == modification_t::USER_MODIFY)) { + this->on_modify(); + } if (val.pladdr.is_paddr()) { val.pladdr = maybe_generate_relative(val.pladdr.get_paddr()); } diff --git a/src/crimson/os/seastore/lba/lba_btree_node.h b/src/crimson/os/seastore/lba/lba_btree_node.h index cabb652bebf..92e2c333a53 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.h +++ b/src/crimson/os/seastore/lba/lba_btree_node.h @@ -141,7 +141,8 @@ struct LBALeafNode void update( internal_const_iterator_t iter, - lba_map_val_t val) final; + lba_map_val_t val, + modification_t mod) final; internal_const_iterator_t insert( internal_const_iterator_t iter, From a35a92639f5dc8d6a98415711242f2491fb45f46 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sun, 14 Jun 2026 14:52:00 +0800 Subject: [PATCH 305/596] journal/ObjectPlayer: don't acquire locks in destructor ~ObjectPlayer took m_timer_lock only to assert two invariants. but that lock is borrowed by reference from the caller's SafeTimer, and an ObjectPlayer can outlive it: a C_Fetch/C_WatchFetch completion on the librados finisher may hold the last reference and run ~ObjectPlayer after the timer and its lock are already gone. re-taking the freed lock is a heap-use-after-free, which unittest_journal hits on arm64 under ASan: ==ERROR: AddressSanitizer: heap-use-after-free journal::ObjectPlayer::~ObjectPlayer ObjectPlayer.cc:63 journal::ObjectPlayer::C_WatchFetch::~C_WatchFetch journal::ObjectPlayer::C_Fetch::finish the lock isn't needed though: at refcount 0 the watch has been cancelled (m_watch_ctx == nullptr, asserted below) so no timer task references us, and no fetch is in flight since a pending fetch holds a reference. nothing else can touch our state. Furthermore, we can also skip acquiring `m_lock` as well, because, in the destructor, it shouldn't really matter -- if one of these asserts fails because the execution of the destructor races with some `ObjectPlayer` mthod, we would get what the `assert()` was added for. They are here to catch bugs and such a race just being possible is a bug in itself. Fixes: https://tracker.ceph.com/issues/77496 Signed-off-by: Kefu Chai --- src/journal/ObjectPlayer.cc | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/journal/ObjectPlayer.cc b/src/journal/ObjectPlayer.cc index 29893cec2d9..e01355e2f72 100644 --- a/src/journal/ObjectPlayer.cc +++ b/src/journal/ObjectPlayer.cc @@ -59,12 +59,8 @@ ObjectPlayer::ObjectPlayer(librados::IoCtx &ioctx, } ObjectPlayer::~ObjectPlayer() { - { - std::lock_guard timer_locker{m_timer_lock}; - std::lock_guard locker{m_lock}; - ceph_assert(!m_fetch_in_progress); - ceph_assert(m_watch_ctx == nullptr); - } + ceph_assert(!m_fetch_in_progress); + ceph_assert(m_watch_ctx == nullptr); } void ObjectPlayer::fetch(Context *on_finish) { From 7e8c2e062f4ee141c67ffe774b32857319ea9870 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 18 Jun 2026 20:31:13 +0800 Subject: [PATCH 306/596] debian/copyright: silence old-fsf-address-in-copyright-file in order to silence the warnings like: ``` W: rbd-nbd: old-fsf-address-in-copyright-file ``` let's refere to the email address of FSF, instead of its old postal address. See https://lintian.debian.org/tags/old-fsf-address-in-copyright-file.html Signed-off-by: Kefu Chai --- debian/copyright | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/debian/copyright b/debian/copyright index 8dc4b9e4f49..23e4ee2b9bd 100644 --- a/debian/copyright +++ b/debian/copyright @@ -209,9 +209,8 @@ License: GPL-2 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. . - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + You should have received a copy of the GNU General Public License + along with this program. If not, see . . On Debian systems, the complete text of the GNU General Public License version 2 can be found in `/usr/share/common-licenses/GPL-2' file. From 1d3e33140b8f694e75a14f6530ec54712b984179 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Wed, 10 Jun 2026 18:59:50 +0530 Subject: [PATCH 307/596] mgr/dashboard: align RGW role management with Carbon and fix API routing Fixes: https://tracker.ceph.com/issues/77328 Signed-off-by: Sagar Gopale --- src/pybind/mgr/dashboard/controllers/rgw.py | 37 +- .../frontend/cypress/e2e/rgw/accounts.po.ts | 4 +- .../cypress/e2e/rgw/roles.e2e-spec.ts | 31 +- .../frontend/cypress/e2e/rgw/roles.po.ts | 99 +++-- .../src/app/ceph/rgw/models/rgw-role.ts | 22 + .../rgw-account-role-form.component.html | 136 ++++++ .../rgw-account-role-form.component.scss | 0 .../rgw-account-role-form.component.spec.ts | 51 +++ .../rgw-account-role-form.component.ts | 114 +++++ .../rgw-account-roles-list.component.html | 14 + .../rgw-account-roles-list.component.scss | 1 + .../rgw-account-roles-list.component.spec.ts | 71 ++++ .../rgw-account-roles-list.component.ts | 167 ++++++++ .../rgw-user-accounts-details.component.html | 40 +- .../frontend/src/app/ceph/rgw/rgw.module.ts | 6 +- .../app/shared/api/rgw-role.service.spec.ts | 59 +++ .../src/app/shared/api/rgw-role.service.ts | 41 ++ src/pybind/mgr/dashboard/openapi.yaml | 388 ++++++++++-------- .../mgr/dashboard/services/rgw_client.py | 42 +- 19 files changed, 1079 insertions(+), 244 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-role.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.ts diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 9e06597f171..a0ef6965771 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -1230,49 +1230,52 @@ class RgwUser(RgwRESTController): class RGWRoleEndpoints: @staticmethod - def role_list(_): + def role_list(_, account_id: Optional[str] = None): rgw_client = RgwClient.admin_instance() - roles = rgw_client.list_roles() + roles = rgw_client.list_roles(account_id) return roles @staticmethod - def role_create(_, role_name: str = '', role_path: str = '', role_assume_policy_doc: str = ''): + def role_create(_, role_name: str = '', role_path: str = '', role_assume_policy_doc: str = '', + account_id: Optional[str] = None): assert role_name assert role_path rgw_client = RgwClient.admin_instance() - rgw_client.create_role(role_name, role_path, role_assume_policy_doc) + rgw_client.create_role(role_name, role_path, role_assume_policy_doc, account_id) return f'Role {role_name} created successfully' @staticmethod - def role_update(_, role_name: str, max_session_duration: str): + def role_update(_, role_name: str, max_session_duration: str, account_id: Optional[str] = None): assert role_name assert max_session_duration # convert max_session_duration which is in hours to seconds max_session_duration = int(float(max_session_duration) * 3600) rgw_client = RgwClient.admin_instance() - rgw_client.update_role(role_name, str(max_session_duration)) + rgw_client.update_role(role_name, str(max_session_duration), account_id) return f'Role {role_name} updated successfully' @staticmethod - def role_delete(_, role_name: str): + def role_delete(_, role_name: str, account_id: Optional[str] = None): assert role_name rgw_client = RgwClient.admin_instance() - rgw_client.delete_role(role_name) + rgw_client.delete_role(role_name, account_id) return f'Role {role_name} deleted successfully' @staticmethod - def model(role_name: str): + def get(_, role_name: str, account_id: Optional[str] = None): assert role_name rgw_client = RgwClient.admin_instance() - role = rgw_client.get_role(role_name) - model = {'role_name': '', 'max_session_duration': ''} - model['role_name'] = role['RoleName'] - + role = rgw_client.get_role(role_name, account_id) + model = {'role_name': role['RoleName'], 'max_session_duration': ''} # convert maxsessionduration which is in seconds to hours if role['MaxSessionDuration']: model['max_session_duration'] = role['MaxSessionDuration'] / 3600 return model + @staticmethod + def model(role_name: str, account_id: Optional[str] = None): + return RGWRoleEndpoints.get(None, role_name, account_id) + # pylint: disable=C0301 assume_role_policy_help = ( @@ -1317,7 +1320,7 @@ edit_role_form = Form(path='/edit', @CRUDEndpoint( - router=APIRouter('/rgw/roles', Scope.RGW), + router=APIRouter('/rgw/accounts/{account_id}/roles', Scope.RGW), doc=APIDoc("List of RGW roles", "RGW"), actions=[ TableAction(name='Create', permission='create', icon=Icon.ADD.value, @@ -1335,6 +1338,12 @@ edit_role_form = Form(path='/edit', func=RGWRoleEndpoints.role_list, doc=EndpointDoc("List RGW roles") ), + extra_endpoints=[ + ('get', CRUDCollectionMethod( + func=RGWRoleEndpoints.get, + doc=EndpointDoc("Get RGW role") + )) + ], create=CRUDCollectionMethod( func=RGWRoleEndpoints.role_create, doc=EndpointDoc("Create RGW role") diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/accounts.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/accounts.po.ts index 215507045a5..f58932982fd 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/accounts.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/accounts.po.ts @@ -53,7 +53,9 @@ export class AccountsPageHelper extends PageHelper { this.getFirstTableCell(account.name).should('have.text', account.name); this.getTableRow(account.name).within(() => { - cy.get('td').eq(this.columnIndex.tenant).should('have.text', account.tenant); + cy.get('td') + .eq(this.columnIndex.tenant) + .should('have.text', account.tenant || ''); cy.get('td').eq(this.columnIndex.account_id).should('not.be.empty'); cy.get('td').eq(this.columnIndex.email).should('have.text', account.email); cy.get('td').eq(this.columnIndex.max_users).should('have.text', 1000); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.e2e-spec.ts index 87031750385..2026d0dc589 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.e2e-spec.ts @@ -1,20 +1,36 @@ import { RolesPageHelper } from './roles.po'; +import { AccountsPageHelper } from './accounts.po'; describe('RGW roles page', () => { const roles = new RolesPageHelper(); + const accounts = new AccountsPageHelper(); + const accountName = 'roles-test-account'; + const roleName = 'testRole'; + + before(() => { + cy.login(); + accounts.navigateTo('create'); + accounts.create({ name: accountName, email: 'test@example.com' }); + }); + + after(() => { + cy.login(); + accounts.navigateTo(); + accounts.delete(accountName, null, null, true, false, false, false); + }); beforeEach(() => { cy.login(); - roles.navigateTo(); + accounts.navigateTo(); + accounts.getExpandCollapseElement(accountName).click(); + cy.contains('cds-tab-headers button[role="tab"]', 'Roles').click(); + // Wait for the roles list to render + cy.get('cd-rgw-account-roles-list').should('exist'); }); describe('Create, Edit & Delete rgw roles', () => { - const roleName = 'testRole'; - - it('should create rgw roles', () => { - roles.navigateTo('create'); + it('should create rgw role', () => { roles.create(roleName, '/', '{}'); - roles.navigateTo(); roles.checkExist(roleName, true); }); @@ -23,7 +39,8 @@ describe('RGW roles page', () => { }); it('should delete rgw role', () => { - roles.delete(roleName, null, null, true); + roles.deleteRole(roleName); + roles.checkExist(roleName, false); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.po.ts index 920a9d1d110..611a060236e 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/roles.po.ts @@ -1,55 +1,88 @@ import { PageHelper } from '../page-helper.po'; -const pages = { - index: { url: '#/rgw/roles', id: 'cd-crud-table' }, - create: { url: '#/rgw/roles/create', id: 'cd-crud-form' } -}; - export class RolesPageHelper extends PageHelper { - pages = pages; + pages = {}; columnIndex = { - roleName: 2, - path: 3, - arn: 4, - createDate: 5, - maxSessionDuration: 6 + roleName: 1, + path: 2, + arn: 3, + createDate: 4, + maxSessionDuration: 5 }; - @PageHelper.restrictTo(pages.create.url) create(name: string, path: string, policyDocument: string) { - cy.get('[id$="string_role_name_0"]').type(name); - cy.get('[id$="role_assume_policy_doc_2"]').type(policyDocument); - cy.get('[id$="role_path_1"]').type(path); - cy.get("[aria-label='Create Role']").should('exist').click(); - cy.get('cd-crud-table').should('exist'); + cy.get('cd-rgw-account-roles-list cd-table-actions button[aria-label="Create"]') + .should('exist') + .click(); + cy.get('cds-modal').should('be.visible'); + cy.get('#role_name').type(name); + cy.get('#role_assume_policy_doc') + .clear() + .type(policyDocument, { parseSpecialCharSequences: false, delay: 0 }); + cy.get('#role_path').type(path); + cy.get('cds-modal').contains('button', 'Create').click(); + cy.get('cds-modal').should('not.exist'); } edit(name: string, maxSessionDuration: number) { - this.navigateEdit(name); - cy.get('[id$="max_session_duration_1"]').clear().type(maxSessionDuration.toString()); - cy.get("[aria-label='Edit Role']").should('exist').click(); - cy.get('cd-crud-table').should('exist'); + this.getRolesTableCell(this.columnIndex.roleName, name).click(); + this.getRolesTableCell(this.columnIndex.roleName, name) + .parent('tr') + .find('[data-testid="table-action-btn"]') + .should('exist') + .click(); + cy.get('cds-overflow-menu-option[aria-label="Edit"]').should('exist').click(); + cy.get('cds-modal').should('be.visible'); - this.getTableCell(this.columnIndex.roleName, name) - .click() + cy.get('cds-number[formControlName="max_session_duration"] input') + .clear() + .type(maxSessionDuration.toString()); + cy.get('cds-modal').contains('button', 'Edit').click(); + cy.get('cds-modal').should('not.exist'); + + this.getRolesTableCell(this.columnIndex.roleName, name) .parent() - .find(`[cdstabledata]:nth-child(${this.columnIndex.maxSessionDuration})`) + .find(`td:nth-child(${this.columnIndex.maxSessionDuration})`) .should(($elements) => { const roleName = $elements.map((_, el) => el.textContent).get(); expect(roleName).to.include(`${maxSessionDuration} hours`); }); } - @PageHelper.restrictTo(pages.index.url) - checkExist(name: string, exist: boolean) { - this.getTableCell(this.columnIndex.roleName, name).should(($elements) => { - const roleName = $elements.map((_, el) => el.textContent).get(); - if (exist) { - expect(roleName).to.include(name); - } else { - expect(roleName).to.not.include(name); - } + deleteRole(name: string) { + this.getRolesTableCell(this.columnIndex.roleName, name).click(); + this.getRolesTableCell(this.columnIndex.roleName, name) + .parent('tr') + .find('[data-testid="table-action-btn"]') + .should('exist') + .click(); + cy.get('cds-overflow-menu-option[aria-label="Delete"]').should('exist').click(); + cy.get('cds-modal').should('be.visible'); + cy.get('cds-modal [aria-label="confirmation"]').click({ force: true }); + cy.get('cds-modal').contains('button', 'Delete Role').click(); + cy.get('cds-modal').should('not.exist'); + } + + private getRolesTableCell(columnIndex: number, exactContent: string, partialMatch = false) { + cy.get('cd-rgw-account-roles-list').within(() => { + cy.get('.cds--search-close').first().click({ force: true }); + cy.get('.cds--search-input').first().clear({ force: true }).type(exactContent, { delay: 35 }); }); + const selector = `tbody tr td:nth-child(${columnIndex})`; + if (partialMatch) { + return cy.get('cd-rgw-account-roles-list').contains(selector, exactContent); + } + return cy + .get('cd-rgw-account-roles-list') + .contains(selector, new RegExp(`^\\s*${exactContent}\\s*$`, 'i')); + } + + checkExist(name: string, exist: boolean) { + if (exist) { + this.getRolesTableCell(this.columnIndex.roleName, name).should('exist'); + } else { + cy.get('cd-rgw-account-roles-list').contains(name).should('not.exist'); + } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-role.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-role.ts new file mode 100644 index 00000000000..8522e8f1775 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-role.ts @@ -0,0 +1,22 @@ +export interface RgwRole { + RoleId: string; + RoleName: string; + Path: string; + Arn: string; + CreateDate: string; + MaxSessionDuration: number; + AssumeRolePolicyDocument: string; +} + +export interface RgwRoleCreatePayload { + role_name: string; + role_path: string; + role_assume_policy_doc: string; + account_id: string; +} + +export interface RgwRoleUpdatePayload { + role_name: string; + max_session_duration: number; + account_id: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.html new file mode 100644 index 00000000000..11fff876688 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.html @@ -0,0 +1,136 @@ + + +

{{ accountName }}

+

{{ mode }} role

+
+ +
+
+ +
+ Role name + + + + @if (form.showError('role_name', formDir, 'required')) { + This field is required. + } + +
+ + + @if (!isEdit) { + +
+ Path + + + + @if (form.showError('role_path', formDir, 'required')) { + This field is required. + } + +
+ + +
+ Trust policy + + + + @if (form.showError('role_assume_policy_doc', formDir, 'required')) { + This field is required. + } @if (form.showError('role_assume_policy_doc', formDir, 'json')) { + Must be a valid JSON. + } + +
+ } + + + @if (isEdit) { +
+ + + + @if (form.showError('max_session_duration', formDir, 'required')) { + This field is required. + } @if (form.showError('max_session_duration', formDir, 'min')) { + Minimum value is 1 hour. + } @if (form.showError('max_session_duration', formDir, 'max')) { + Maximum value is 12 hours. + } + +
+ } +
+ + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.spec.ts new file mode 100644 index 00000000000..e2f6b2fabb1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ReactiveFormsModule } from '@angular/forms'; + +import { RgwAccountRoleFormComponent } from './rgw-account-role-form.component'; +import { SharedModule } from '~/app/shared/shared.module'; + +describe('RgwAccountRoleFormComponent', () => { + let component: RgwAccountRoleFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ReactiveFormsModule], + declarations: [RgwAccountRoleFormComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(RgwAccountRoleFormComponent); + component = fixture.componentInstance; + component.accountId = 'test-account'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create form correctly on init', () => { + expect(component.form).toBeDefined(); + expect(component.form.contains('role_name')).toBeTruthy(); + expect(component.form.contains('role_path')).toBeTruthy(); + expect(component.form.contains('role_assume_policy_doc')).toBeTruthy(); + }); + + it('should patch value in edit mode', () => { + component.isEdit = true; + component.roleName = 'test-role'; + component.role = { + RoleName: 'test-role', + Path: '/path', + MaxSessionDuration: 3 * 3600 + } as any; + + component.ngOnInit(); + + expect(component.form.get('role_name').value).toBe('test-role'); + expect(component.form.get('max_session_duration').value).toBe(3); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.ts new file mode 100644 index 00000000000..8cd266cdf46 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-role-form/rgw-account-role-form.component.ts @@ -0,0 +1,114 @@ +import { Component, Inject, OnInit, Optional } from '@angular/core'; +import { Validators } from '@angular/forms'; +import { BaseModal } from 'carbon-components-angular'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { RgwRoleService } from '~/app/shared/api/rgw-role.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { RgwRole } from '../models/rgw-role'; + +@Component({ + selector: 'cd-rgw-account-role-form', + templateUrl: './rgw-account-role-form.component.html', + styleUrls: ['./rgw-account-role-form.component.scss'], + standalone: false +}) +export class RgwAccountRoleFormComponent extends BaseModal implements OnInit { + form: CdFormGroup; + mode: string; + + constructor( + @Optional() @Inject('accountId') public accountId: string, + @Optional() @Inject('accountName') public accountName: string, + @Optional() @Inject('roleName') public roleName: string, + @Optional() @Inject('isEdit') public isEdit = false, + @Optional() @Inject('role') public role: RgwRole = null, + private formBuilder: CdFormBuilder, + public actionLabels: ActionLabelsI18n, + private rgwRoleService: RgwRoleService, + private notificationService: NotificationService + ) { + super(); + } + + ngOnInit(): void { + this.mode = this.isEdit ? this.actionLabels.EDIT : this.actionLabels.CREATE; + this.createForm(); + if (this.isEdit && this.role) { + this.form.patchValue({ + role_name: this.role.RoleName, + role_path: this.role.Path || '/', + max_session_duration: this.role.MaxSessionDuration ? this.role.MaxSessionDuration / 3600 : 1 + }); + } + } + + private createForm() { + this.form = this.formBuilder.group({ + role_name: [{ value: '', disabled: this.isEdit }, [Validators.required]], + role_path: [{ value: '', disabled: this.isEdit }, [Validators.required]], + role_assume_policy_doc: [''], + max_session_duration: [1] + }); + + CdValidators.validateIf(this.form.get('role_assume_policy_doc'), () => !this.isEdit, [ + Validators.required, + CdValidators.json() + ]); + + CdValidators.validateIf(this.form.get('max_session_duration'), () => this.isEdit, [ + Validators.required, + Validators.min(1), + Validators.max(12) + ]); + } + + onSubmit() { + if (this.form.invalid) { + return; + } + + const payload = this.form.getRawValue(); + payload.account_id = this.accountId; + if (!this.isEdit) { + delete payload.max_session_duration; + } + + if (this.isEdit) { + this.rgwRoleService + .update(this.roleName, { + role_name: this.roleName, + max_session_duration: payload.max_session_duration, + account_id: this.accountId + }) + .subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Role updated successfully` + ); + this.closeModal(); + }, + error: () => { + this.form.setErrors({ cdSubmitButton: true }); + } + }); + } else { + this.rgwRoleService.create(payload).subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Role created successfully` + ); + this.closeModal(); + }, + error: () => { + this.form.setErrors({ cdSubmitButton: true }); + } + }); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.html new file mode 100644 index 00000000000..209f15a6daa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.html @@ -0,0 +1,14 @@ + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.scss new file mode 100644 index 00000000000..dfca32bf3c2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.scss @@ -0,0 +1 @@ +/* Scss stylesheet for RGW account roles list */ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.spec.ts new file mode 100644 index 00000000000..6beb2fa5ccb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { RgwAccountRolesListComponent } from './rgw-account-roles-list.component'; +import { RgwRoleService } from '~/app/shared/api/rgw-role.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; + +describe('RgwAccountRolesListComponent', () => { + let component: RgwAccountRolesListComponent; + let fixture: ComponentFixture; + let rgwRoleService: RgwRoleService; + let notificationService: NotificationService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, RouterTestingModule, SharedModule], + declarations: [RgwAccountRolesListComponent], + providers: [ + { + provide: AuthStorageService, + useValue: { + getPermissions: () => ({ rgw: { create: true, update: true, delete: true } }) + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(RgwAccountRolesListComponent); + component = fixture.componentInstance; + rgwRoleService = TestBed.inject(RgwRoleService); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show'); + component.accountId = 'test-account'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load roles on init', () => { + const roles = [{ RoleName: 'test-role' }]; + spyOn(rgwRoleService, 'list').and.returnValue(of(roles)); + component.loadRoles(); + expect(rgwRoleService.list).toHaveBeenCalledWith('test-account'); + component.data$.subscribe((res) => { + expect(res).toEqual(roles); + }); + }); + + it('should delete a role and show notification', () => { + spyOn(rgwRoleService, 'delete').and.returnValue(of(null)); + spyOn(component, 'loadRoles'); + component.selection.selected = [{ RoleName: 'test-role' }]; + spyOn(TestBed.inject(ModalCdsService), 'show').and.callFake((_componentClass, config) => { + config.submitActionObservable().subscribe(); + return null; + }); + + component.deleteRole(); + expect(rgwRoleService.delete).toHaveBeenCalledWith('test-role', 'test-account'); + expect(notificationService.show).toHaveBeenCalled(); + expect(component.loadRoles).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.ts new file mode 100644 index 00000000000..af1c13f0a4b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-account-roles-list/rgw-account-roles-list.component.ts @@ -0,0 +1,167 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { TableComponent } from '~/app/shared/datatable/table/table.component'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { RgwRoleService } from '~/app/shared/api/rgw-role.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { RgwAccountRoleFormComponent } from '../rgw-account-role-form/rgw-account-role-form.component'; +import { Observable, Subscriber, of } from 'rxjs'; +import { Permission } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; + +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { DurationPipe } from '~/app/shared/pipes/duration.pipe'; +import { RgwRole } from '../models/rgw-role'; + +@Component({ + selector: 'cd-rgw-account-roles-list', + templateUrl: './rgw-account-roles-list.component.html', + styleUrls: ['./rgw-account-roles-list.component.scss'], + standalone: false +}) +export class RgwAccountRolesListComponent implements OnInit, OnChanges { + @Input() + accountId: string; + + @Input() + accountName: string; + + @ViewChild('table') + table: TableComponent; + + columns: CdTableColumn[] = []; + data$: Observable; + tableActions: CdTableAction[] = []; + selection: CdTableSelection = new CdTableSelection(); + permission: Permission; + + constructor( + public actionLabels: ActionLabelsI18n, + private rgwRoleService: RgwRoleService, + private modalService: ModalCdsService, + private authStorageService: AuthStorageService, + private cdDatePipe: CdDatePipe, + private durationPipe: DurationPipe, + private notificationService: NotificationService + ) { + this.permission = this.authStorageService.getPermissions().rgw; + } + + ngOnInit(): void { + this.loadRoles(); + this.columns = [ + { + name: $localize`Role name`, + prop: 'RoleName', + flexGrow: 2 + }, + { + name: $localize`Path`, + prop: 'Path', + flexGrow: 2 + }, + { + name: $localize`Arn`, + prop: 'Arn', + flexGrow: 3 + }, + { + name: $localize`Created at`, + prop: 'CreateDate', + flexGrow: 2, + pipe: this.cdDatePipe + }, + { + name: $localize`Max session duration`, + prop: 'MaxSessionDuration', + flexGrow: 2, + pipe: this.durationPipe + } + ]; + + this.tableActions = [ + { + permission: 'create', + icon: Icons.add, + click: () => this.openRoleForm(false), + name: this.actionLabels.CREATE, + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + permission: 'update', + icon: Icons.edit, + click: () => this.openRoleForm(true), + name: this.actionLabels.EDIT + }, + { + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteRole(), + name: this.actionLabels.DELETE, + disable: () => !this.selection.hasSelection + } + ]; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.accountId) { + this.loadRoles(); + } + } + + loadRoles(): void { + if (!this.accountId) { + this.data$ = of([]); + return; + } + this.data$ = this.rgwRoleService.list(this.accountId); + } + + updateSelection(selection: CdTableSelection): void { + this.selection = selection; + } + + openRoleForm(isEdit: boolean): void { + const role = isEdit ? this.selection.first() : null; + const modalRef = this.modalService.show(RgwAccountRoleFormComponent, { + accountId: this.accountId, + accountName: this.accountName, + roleName: role ? role.RoleName : '', + isEdit: isEdit, + role: role + }); + modalRef?.close?.subscribe(() => this.loadRoles()); + } + + deleteRole(): void { + const roleName = this.selection.first().RoleName; + this.modalService.show(DeleteConfirmationModalComponent, { + itemDescription: $localize`Role`, + itemNames: [roleName], + submitActionObservable: () => { + return new Observable((observer: Subscriber) => { + this.rgwRoleService.delete(roleName, this.accountId).subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Role deleted successfully` + ); + observer.next(); + observer.complete(); + this.loadRoles(); + }, + error: (err) => { + observer.error(err); + } + }); + }); + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html index 77a4008580d..f5511853d44 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html @@ -1,13 +1,27 @@ - -
- Account quota - - -
- - -
- Bucket quota - - -
+@if (selection) { + + +
+ @if (selection.quota) { +
+ Account quota + +
+ } @if (selection.bucket_quota) { +
+ Bucket quota + +
+ } +
+
+ +
+ +
+
+
+} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index c573b617bf0..585f3c25f12 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -120,6 +120,8 @@ import { RgwTopicFormComponent } from './rgw-topic-form/rgw-topic-form.component import { RgwBucketNotificationListComponent } from './rgw-bucket-notification-list/rgw-bucket-notification-list.component'; import { RgwNotificationFormComponent } from './rgw-notification-form/rgw-notification-form.component'; import { ComponentsModule } from '~/app/shared/components/components.module'; +import { RgwAccountRolesListComponent } from './rgw-account-roles-list/rgw-account-roles-list.component'; +import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account-role-form.component'; @NgModule({ imports: [ @@ -229,7 +231,9 @@ import { ComponentsModule } from '~/app/shared/components/components.module'; RgwTopicDetailsComponent, RgwTopicFormComponent, RgwBucketNotificationListComponent, - RgwNotificationFormComponent + RgwNotificationFormComponent, + RgwAccountRolesListComponent, + RgwAccountRoleFormComponent ], providers: [TitleCasePipe] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.spec.ts new file mode 100644 index 00000000000..3ec6598338d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.spec.ts @@ -0,0 +1,59 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { RgwRoleService } from './rgw-role.service'; + +describe('RgwRoleService', () => { + let service: RgwRoleService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule] + }); + service = TestBed.inject(RgwRoleService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list with account_id', () => { + service.list('test-account').subscribe(); + const req = httpTesting.expectOne('api/rgw/accounts/test-account/roles'); + expect(req.request.method).toBe('GET'); + }); + + it('should call get with account_id', () => { + service.get('test-role', 'test-account').subscribe(); + const req = httpTesting.expectOne('api/rgw/accounts/test-account/roles/test-role'); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + const payload = { role_name: 'test', account_id: 'test-account' }; + service.create(payload as any).subscribe(); + const req = httpTesting.expectOne('api/rgw/accounts/test-account/roles'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(payload); + }); + + it('should call update', () => { + const payload = { max_session_duration: 2, account_id: 'test-account' }; + service.update('test-role', payload as any).subscribe(); + const req = httpTesting.expectOne('api/rgw/accounts/test-account/roles'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(payload); + }); + + it('should call delete with account_id', () => { + service.delete('test-role', 'test-account').subscribe(); + const req = httpTesting.expectOne('api/rgw/accounts/test-account/roles/test-role'); + expect(req.request.method).toBe('DELETE'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.ts new file mode 100644 index 00000000000..57afbb253e8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-role.service.ts @@ -0,0 +1,41 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + RgwRole, + RgwRoleCreatePayload, + RgwRoleUpdatePayload +} from '~/app/ceph/rgw/models/rgw-role'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwRoleService { + constructor(private http: HttpClient) {} + + private getUrl(accountId: string): string { + return `api/rgw/accounts/${accountId}/roles`; + } + + list(accountId: string): Observable { + return this.http.get(this.getUrl(accountId)); + } + + get(roleName: string, accountId: string): Observable { + return this.http.get(`${this.getUrl(accountId)}/${roleName}`); + } + + create(role: RgwRoleCreatePayload): Observable { + const accountId = role.account_id; + return this.http.post(this.getUrl(accountId), role); + } + + update(_roleName: string, payload: RgwRoleUpdatePayload): Observable { + const accountId = payload.account_id; + return this.http.put(this.getUrl(accountId), payload); + } + + delete(roleName: string, accountId: string): Observable { + return this.http.delete(`${this.getUrl(accountId)}/${roleName}`); + } +} diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index abb7a7351ff..1a46d821cc6 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -18007,6 +18007,228 @@ paths: summary: Enable/Disable RGW Account/Bucket quota tags: - RgwUserAccounts + /api/rgw/accounts/{account_id}/roles: + get: + parameters: + - allowEmptyValue: true + in: path + name: account_id + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: List RGW roles + tags: + - RGW + post: + parameters: + - allowEmptyValue: true + in: path + name: account_id + schema: + type: integer + requestBody: + content: + application/json: + schema: + properties: + role_assume_policy_doc: + default: '' + type: string + role_name: + default: '' + type: string + role_path: + default: '' + type: string + type: object + responses: + '201': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource created. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Create RGW role + tags: + - RGW + put: + parameters: + - allowEmptyValue: true + in: path + name: account_id + schema: + type: integer + requestBody: + content: + application/json: + schema: + properties: + max_session_duration: + type: string + role_name: + type: string + required: + - role_name + - max_session_duration + type: object + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource updated. + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Edit RGW role + tags: + - RGW + /api/rgw/accounts/{account_id}/roles/{role_name}: + delete: + parameters: + - in: path + name: role_name + required: true + schema: + type: string + - allowEmptyValue: true + in: path + name: account_id + schema: + type: integer + responses: + '202': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Operation is still executing. Please check the task queue. + '204': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: Resource deleted. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Delete RGW role + tags: + - RGW + get: + parameters: + - in: path + name: role_name + required: true + schema: + type: string + - allowEmptyValue: true + in: path + name: account_id + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + type: object + application/vnd.ceph.api.v1.0+json: + schema: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Get RGW role + tags: + - RGW /api/rgw/bucket: get: parameters: @@ -19857,172 +20079,6 @@ paths: - jwt: [] tags: - RgwRealm - /api/rgw/roles: - get: - parameters: [] - responses: - '200': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: OK - '400': - description: Operation exception. Please check the response body for details. - '401': - description: Unauthenticated access. Please login first. - '403': - description: Unauthorized access. Please check your permissions. - '500': - description: Unexpected error. Please check the response body for the stack - trace. - security: - - jwt: [] - summary: List RGW roles - tags: - - RGW - post: - parameters: [] - requestBody: - content: - application/json: - schema: - properties: - role_assume_policy_doc: - default: '' - type: string - role_name: - default: '' - type: string - role_path: - default: '' - type: string - type: object - responses: - '201': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Resource created. - '202': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Operation is still executing. Please check the task queue. - '400': - description: Operation exception. Please check the response body for details. - '401': - description: Unauthenticated access. Please login first. - '403': - description: Unauthorized access. Please check your permissions. - '500': - description: Unexpected error. Please check the response body for the stack - trace. - security: - - jwt: [] - summary: Create RGW role - tags: - - RGW - put: - parameters: [] - requestBody: - content: - application/json: - schema: - properties: - max_session_duration: - type: string - role_name: - type: string - required: - - role_name - - max_session_duration - type: object - responses: - '200': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Resource updated. - '202': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Operation is still executing. Please check the task queue. - '400': - description: Operation exception. Please check the response body for details. - '401': - description: Unauthenticated access. Please login first. - '403': - description: Unauthorized access. Please check your permissions. - '500': - description: Unexpected error. Please check the response body for the stack - trace. - security: - - jwt: [] - summary: Edit RGW role - tags: - - RGW - /api/rgw/roles/{role_name}: - delete: - parameters: - - in: path - name: role_name - required: true - schema: - type: string - responses: - '202': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Operation is still executing. Please check the task queue. - '204': - content: - application/json: - schema: - type: object - application/vnd.ceph.api.v1.0+json: - schema: - type: object - description: Resource deleted. - '400': - description: Operation exception. Please check the response body for details. - '401': - description: Unauthenticated access. Please login first. - '403': - description: Unauthorized access. Please check your permissions. - '500': - description: Unexpected error. Please check the response body for the stack - trace. - security: - - jwt: [] - summary: Delete RGW role - tags: - - RGW /api/rgw/site: get: parameters: diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index e4ab27911ec..a36eded72dd 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -1020,19 +1020,31 @@ class RgwClient(RestClient): except RequestException as e: raise DashboardException(msg=str(e), component='rgw') - def list_roles(self) -> List[Dict[str, Any]]: + def list_roles(self, account_id: Optional[str] = None) -> List[Dict[str, Any]]: rgw_list_roles_command = ['role', 'list'] + if account_id: + rgw_list_roles_command += ['--account-id', account_id] code, roles, err = mgr.send_rgwadmin_command(rgw_list_roles_command) - if code < 0: + if code != 0: logger.warning('Error listing roles with code %d: %s', code, err) return [] + if isinstance(roles, dict): + roles = roles.get('Roles', []) + + result = [] for role in roles: + if isinstance(role, dict) and 'member' in role: + role = role['member'] + if not isinstance(role, dict): + continue if 'PermissionPolicies' not in role: role['PermissionPolicies'] = [] - return roles + result.append(role) + return result - def create_role(self, role_name: str, role_path: str, role_assume_policy_doc: str) -> None: + def create_role(self, role_name: str, role_path: str, role_assume_policy_doc: str, + account_id: Optional[str] = None) -> None: try: json.loads(role_assume_policy_doc) except: # noqa: E722 @@ -1065,6 +1077,8 @@ class RgwClient(RestClient): rgw_create_role_command = ['role', 'create', '--role-name', role_name, '--path', role_path] if role_assume_policy_doc: rgw_create_role_command += ['--assume-role-policy-doc', f"{role_assume_policy_doc}"] + if account_id: + rgw_create_role_command += ['--account-id', account_id] code, _roles, _err = mgr.send_rgwadmin_command(rgw_create_role_command, stdout_as_json=False) @@ -1084,25 +1098,34 @@ class RgwClient(RestClient): component='rgw') return lifecycle_progress - def get_role(self, role_name: str): + def get_role(self, role_name: str, account_id: Optional[str] = None): rgw_get_role_command = ['role', 'get', '--role-name', role_name] + if account_id: + rgw_get_role_command += ['--account-id', account_id] code, role, _err = mgr.send_rgwadmin_command(rgw_get_role_command) if code != 0: raise DashboardException(msg=f'Error getting role with code {code}: {_err}', component='rgw') + if isinstance(role, dict) and 'role' in role: + role = role['role'] return role - def update_role(self, role_name: str, max_session_duration: str): + def update_role(self, role_name: str, max_session_duration: str, + account_id: Optional[str] = None): rgw_update_role_command = ['role', 'update', '--role-name', role_name, '--max_session_duration', max_session_duration] + if account_id: + rgw_update_role_command += ['--account-id', account_id] code, _, _err = mgr.send_rgwadmin_command(rgw_update_role_command, stdout_as_json=False) if code != 0: raise DashboardException(msg=f'Error updating role with code {code}: {_err}', component='rgw') - def delete_role(self, role_name: str) -> None: + def delete_role(self, role_name: str, account_id: Optional[str] = None) -> None: rgw_delete_role_command = ['role', 'delete', '--role-name', role_name] + if account_id: + rgw_delete_role_command += ['--account-id', account_id] code, _, _err = mgr.send_rgwadmin_command(rgw_delete_role_command, stdout_as_json=False) if code != 0: @@ -2734,10 +2757,11 @@ class RgwMultisite: def get_user_list(self, zoneName=None, realmName=None): user_list = [] + rgw_user_list_cmd = ['user', 'list'] if zoneName: - rgw_user_list_cmd = ['user', 'list', '--rgw-zone', zoneName] + rgw_user_list_cmd.extend(['--rgw-zone', zoneName]) if realmName: - rgw_user_list_cmd = ['user', 'list', '--rgw-realm', realmName] + rgw_user_list_cmd.extend(['--rgw-realm', realmName]) try: exit_code, out, _ = mgr.send_rgwadmin_command(rgw_user_list_cmd) if exit_code > 0: From 176de7b9030919ee268c733de387170ea60ca454 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 24 Apr 2026 16:17:40 +0530 Subject: [PATCH 308/596] ceph/deployment: Adding class SSLParameters for storing cert, key and ca-cert for ssl and other features Signed-off-by: Rabinarayan Panigrahi --- .../ceph/deployment/service_spec.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index b5726820ee5..1d4b913c4c2 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -3959,6 +3959,68 @@ class SMBClusterBindIPSpec: return out +class SSLParameters: + def __init__( + self, + enabled: bool = False, + ssl_cert: Optional[str] = None, + ssl_key: Optional[str] = None, + ssl_ca_cert: Optional[str] = None, + certificate_source: Optional[str] = None, + ): + self.enabled = enabled + self.ssl_cert = ssl_cert + self.ssl_key = ssl_key + self.ssl_ca_cert = ssl_ca_cert + self.certificate_source = certificate_source + self.validate() + + def validate( + self, + component: str = "ssl", + ca_cert_required: bool = False, + ) -> None: + if not self.enabled: + return + missing: list[Any] = [] + if not self.certificate_source: + missing.append("certificate_source") + if self.certificate_source == 'inline': + if not self.ssl_cert: + missing.append("ssl_cert") + if not self.ssl_key: + missing.append("ssl_key") + if ca_cert_required and not self.ssl_ca_cert: + missing.append("ssl_ca_cert") + if missing: + raise ValueError( + f"[{component}] SSL is enabled " + f"but the following fields are missing: {', '.join(missing)}" + ) + + @classmethod + def from_dict(cls, data: Any) -> 'SSLParameters': + if not isinstance(data, dict): + return cls(enabled=False) + + return cls( + enabled=data.get('enabled', False), + ssl_cert=data.get('ssl_cert'), + ssl_key=data.get('ssl_key'), + ssl_ca_cert=data.get('ssl_ca_cert'), + certificate_source=data.get('certificate_source'), + ) + + def to_json(self) -> Dict[str, Any]: + return { + 'enabled': self.enabled, + 'ssl_cert': self.ssl_cert, + 'ssl_key': self.ssl_key, + 'ssl_ca_cert': self.ssl_ca_cert, + 'certificate_source': self.certificate_source, + } + + class SMBExternalCephCluster: """Configure access to a non-local Ceph cluster for SMB services.""" def __init__( @@ -4101,6 +4163,8 @@ class SMBSpec(ServiceSpec): # not listed the default port will be used. custom_ports: Optional[Dict[str, int]] = None, bind_addrs: Optional[List[SMBClusterBindIPSpec]] = None, + ssl: Optional[bool] = None, + ssl_certificates: Optional[Dict[str, SSLParameters]] = None, # === remote control server === remote_control_ssl_cert: Optional[str] = None, remote_control_ssl_key: Optional[str] = None, @@ -4124,12 +4188,23 @@ class SMBSpec(ServiceSpec): ) -> None: if service_type != self.service_type: raise ValueError(f'invalid service_type: {service_type!r}') + + self.ssl_certificates = { + name: ( + value + if isinstance(value, SSLParameters) + else SSLParameters.from_dict(value) + ) + for name, value in (ssl_certificates or {}).items() + } + any_ssl = any(p.enabled for p in self.ssl_certificates.values()) super().__init__( self.service_type, service_id=service_id, placement=placement, count=count, config=config, + ssl=any_ssl, unmanaged=unmanaged, preview_only=preview_only, networks=networks, @@ -4200,6 +4275,12 @@ class SMBSpec(ServiceSpec): for key in self.custom_ports or {}: if key not in self._valid_service_names: raise ValueError(f'{key} is not a valid service name') + # TLS certificate validation + for feature_name, ssl_params in self.ssl_certificates.items(): + ssl_params.validate( + component=feature_name, + ca_cert_required=feature_name in smbconst.CA_CERT_REQUIRED_FEATURES + ) def _derive_cluster_uri(self, uri: str, objname: str) -> str: if not uri.startswith(('rados://', 'mem:')): @@ -4253,6 +4334,10 @@ class SMBSpec(ServiceSpec): spec['ceph_cluster_configs'] = [ c.to_json() for c in spec['ceph_cluster_configs'] ] + if spec and spec.get('ssl_certificates'): + spec['ssl_certificates'] = { + k: v.to_json() for k, v in self.ssl_certificates.items() + } return obj From 16553bfa2360d85216c76f0dff7105c702d69b89 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Wed, 29 Apr 2026 06:40:12 +0530 Subject: [PATCH 309/596] mgr/cephadm: Add function all to get ssl certificates A function call added to get ssl certificates from ssl_certificates buffer. it returns TLSCredentials: Signed-off-by: Rabinarayan Panigrahi --- .../mgr/cephadm/services/cephadmservice.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/cephadm/services/cephadmservice.py b/src/pybind/mgr/cephadm/services/cephadmservice.py index c95d845aa8a..649474c3be5 100644 --- a/src/pybind/mgr/cephadm/services/cephadmservice.py +++ b/src/pybind/mgr/cephadm/services/cephadmservice.py @@ -373,12 +373,21 @@ class CephadmService(metaclass=ABCMeta): self.mgr.cert_mgr.save_self_signed_cert_key_pair(svc_name, tls_creds, host=daemon_spec.host, label=label) return tls_creds + def _get_certificates_from_spec_ssl_certificates( + self, + svc_spec: ServiceSpec, + daemon_spec: CephadmDaemonDeploySpec, + feature: Optional[str] = None + ) -> TLSCredentials: + return EMPTY_TLS_CREDENTIALS + def get_certificates(self, daemon_spec: CephadmDaemonDeploySpec, ips: List[str] = [], fqdns: List[str] = [], custom_sans: List[str] = [], - ca_cert_required: bool = False + ca_cert_required: bool = False, + feature: Optional[str] = None ) -> TLSCredentials: svc_spec = cast(ServiceSpec, self.mgr.spec_store[daemon_spec.service_name].spec) @@ -398,6 +407,7 @@ class CephadmService(metaclass=ABCMeta): ips=ips, fqdns=fqdns, custom_sans=custom_sans, + feature=feature, ) def get_certificates_generic( @@ -414,6 +424,7 @@ class CephadmService(metaclass=ABCMeta): custom_sans: Optional[List[str]] = None, ips: Optional[List[str]] = None, fqdns: Optional[List[str]] = None, + feature: Optional[str] = None, ) -> TLSCredentials: ips = ips or [self.mgr.inventory.get_addr(daemon_spec.host)] @@ -423,7 +434,9 @@ class CephadmService(metaclass=ABCMeta): cert_source = getattr(svc_spec, cert_source_attr, None) logger.debug(f'Getting certificate for {svc_spec.service_name()} using source: {cert_source}') - if cert_source == CertificateSource.INLINE.value: + if feature is not None: + return self._get_certificates_from_spec_ssl_certificates(svc_spec, daemon_spec, feature) + elif cert_source == CertificateSource.INLINE.value: return self._get_certificates_from_spec(svc_spec, daemon_spec, cert_attr, key_attr, cert_name, key_name, ca_cert_attr, ca_cert_name) elif cert_source == CertificateSource.REFERENCE.value: return self._get_certificates_from_certmgr_store(svc_spec, fqdns, cert_name, key_name, ca_cert_name) From 8202dae834125ee3d9ffdf32a904645a3ed0393b Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Wed, 29 Apr 2026 07:26:23 +0530 Subject: [PATCH 310/596] ceph/deployment: Adding smb to REQUIRES_CERTIFICATES list Signed-off-by: Rabinarayan Panigrahi --- src/python-common/ceph/deployment/service_spec.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 1d4b913c4c2..8e183f2414b 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -907,6 +907,7 @@ class ServiceSpec(object): 'mgmt-gateway': {'user_cert_allowed': True, 'scope': 'global', 'requires_ca_cert': False}, 'nvmeof': {'user_cert_allowed': True, 'scope': 'service', 'requires_ca_cert': False}, 'nfs': {'user_cert_allowed': True, 'scope': 'service', 'requires_ca_cert': True}, + 'smb': {'user_cert_allowed': True, 'scope': 'service', 'requires_ca_cert': True}, # Services that only support cephadm-signed certificates 'agent': {'user_cert_allowed': False, 'scope': 'host', 'requires_ca_cert': False}, From ee6fd847b03e9d2c68c9f2f7644b8b9bede1437a Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 8 May 2026 10:58:09 +0530 Subject: [PATCH 311/596] mgr/cephadm: Add function to get ssl certificate from ssl_certificates A function is added _get_certificates_from_spec_ssl_certificates to get certificates from ssl_certificates buffer. it returns TLSCredentials from SSLParameters of ssl_certificates[feature] Signed-off-by: Rabinarayan Panigrahi Signed-off-by: Avan Thakkar --- src/pybind/mgr/cephadm/services/smb.py | 135 ++++++++++++++++++------- 1 file changed, 101 insertions(+), 34 deletions(-) diff --git a/src/pybind/mgr/cephadm/services/smb.py b/src/pybind/mgr/cephadm/services/smb.py index fbb4fcf57d8..831b89dc1ef 100644 --- a/src/pybind/mgr/cephadm/services/smb.py +++ b/src/pybind/mgr/cephadm/services/smb.py @@ -16,6 +16,12 @@ from typing import ( ) from mgr_module import HandleCommandResult +from cephadm.tlsobject_types import TLSObjectScope, TLSCredentials, EMPTY_TLS_CREDENTIALS +from ceph.smb.constants import ( + KEYBRIDGE, + REMOTE_CONTROL, + FEATURES, +) from ceph.deployment.service_spec import ( SMBExternalCephCluster, @@ -31,6 +37,7 @@ from .cephadmservice import ( CephadmDaemonDeploySpec, simplified_keyring, ) +from ..tlsobject_types import TLSCredentials, EMPTY_TLS_CREDENTIALS from ..schedule import DaemonPlacement if TYPE_CHECKING: @@ -145,14 +152,80 @@ class SMBService(CephService): ) return daemon_spec + def _feature_tls_filename(self, feature: str) -> Optional[str]: + return { + KEYBRIDGE: "keybridge", + REMOTE_CONTROL: "remote_control" + }.get(feature) + + # Flat SSL fields on SMBSpec per feature, used as a fallback when + # ssl_certificates is not set (backward compatibility). + _FEATURE_FLAT_ATTRS: Dict[str, Tuple[str, str, str]] = { + REMOTE_CONTROL: ( + 'remote_control_ssl_cert', + 'remote_control_ssl_key', + 'remote_control_ca_cert', + ), + KEYBRIDGE: ( + 'keybridge_kmip_ssl_cert', + 'keybridge_kmip_ssl_key', + 'keybridge_kmip_ca_cert', + ), + } + + def _get_feature_certs( + self, + daemon_spec: CephadmDaemonDeploySpec, + smb_spec: SMBSpec, + support_feature: str, + feature: str, + ) -> TLSCredentials: + feature_creds = self.get_certificates( + daemon_spec, ca_cert_required=True, feature=feature + ) + if feature_creds: + return feature_creds + flat_attrs = self._FEATURE_FLAT_ATTRS.get(support_feature) + if flat_attrs is None: + return EMPTY_TLS_CREDENTIALS + cert_attr, key_attr, ca_attr = flat_attrs + tc = TLSCredentials( + cert=getattr(smb_spec, cert_attr, None) or '', + key=getattr(smb_spec, key_attr, None) or '', + ca_cert=getattr(smb_spec, ca_attr, None) or '', + ) + return tc if tc else EMPTY_TLS_CREDENTIALS + + def _get_certificates_from_spec_ssl_certificates( + self, + svc_spec: ServiceSpec, + _daemon_spec: CephadmDaemonDeploySpec, + feature: Optional[str] = None, + ) -> TLSCredentials: + smb_spec = cast(SMBSpec, svc_spec) + ssl_certs = getattr(smb_spec, 'ssl_certificates', None) + if not ssl_certs: + return EMPTY_TLS_CREDENTIALS + feature_key = feature if feature is not None else '' + ssl_params = smb_spec.ssl_certificates.get(feature_key) + if not ssl_params: + return EMPTY_TLS_CREDENTIALS + return TLSCredentials( + cert=ssl_params.ssl_cert or '', + key=ssl_params.ssl_key or '', + ca_cert=ssl_params.ssl_ca_cert or '', + ) + def generate_config( self, daemon_spec: CephadmDaemonDeploySpec ) -> Tuple[Dict[str, Any], List[str]]: logger.debug('smb generate_config') assert self.TYPE == daemon_spec.daemon_type + super().register_for_certificates(daemon_spec) smb_spec = cast( SMBSpec, self.mgr.spec_store[daemon_spec.service_name].spec ) + ssl_certificates = smb_spec.ssl_certificates or {} config_blobs: Dict[str, Any] = {} config_blobs['cluster_id'] = smb_spec.cluster_id @@ -181,40 +254,34 @@ class SMBService(CephService): config_blobs['service_ports'] = smb_spec.service_ports() if smb_spec.bind_addrs: config_blobs['bind_networks'] = smb_spec.bind_networks() - if 'remote-control' in smb_spec.features: - files = config_blobs.setdefault('files', {}) - _add_cfg( - files, - 'remote_control.ssl.crt', - self._cert_or_uri(smb_spec.remote_control_ssl_cert), - ) - _add_cfg( - files, - 'remote_control.ssl.key', - self._cert_or_uri(smb_spec.remote_control_ssl_key), - ) - _add_cfg( - files, - 'remote_control.ca.crt', - self._cert_or_uri(smb_spec.remote_control_ca_cert), - ) - if 'keybridge' in smb_spec.features: - files = config_blobs.setdefault('files', {}) - _add_cfg( - files, - 'keybridge.ssl.crt', - self._cert_or_uri(smb_spec.keybridge_kmip_ssl_cert), - ) - _add_cfg( - files, - 'keybridge.ssl.key', - self._cert_or_uri(smb_spec.keybridge_kmip_ssl_key), - ) - _add_cfg( - files, - 'keybridge.ca.crt', - self._cert_or_uri(smb_spec.keybridge_kmip_ca_cert), - ) + for support_feature in smb_spec.features: + feature = self._feature_tls_filename(support_feature) + if feature is not None: + feature_creds = self._get_feature_certs( + daemon_spec, smb_spec, support_feature, feature + ) + if feature_creds: + files = config_blobs.setdefault('files', {}) + cert_pem = self._cert_or_uri(feature_creds.cert) + key_pem = self._cert_or_uri(feature_creds.key) + ca_pem = self._cert_or_uri(feature_creds.ca_cert) + _add_cfg(files, f'{feature}.ssl.crt', cert_pem) + _add_cfg(files, f'{feature}.ssl.key', key_pem) + _add_cfg(files, f'{feature}.ca.crt', ca_pem) + svc_name = smb_spec.service_name() + host = daemon_spec.host + cert_name = f'smb_{feature}_ssl_cert' + key_name = f'smb_{feature}_ssl_key' + ca_cert_name = f'smb_{feature}_ca_cert' + self.mgr.cert_mgr.register_cert_key_pair( + 'smb', cert_name, key_name, self.SCOPE, ca_cert_name + ) + if cert_pem: + self.mgr.cert_mgr.save_cert(cert_name, cert_pem, svc_name, host, user_made=True) + if key_pem: + self.mgr.cert_mgr.save_key(key_name, key_pem, svc_name, host, user_made=True) + if ca_pem: + self.mgr.cert_mgr.save_cert(ca_cert_name, ca_pem, svc_name, host, user_made=True) for ext_cluster in smb_spec.ceph_cluster_configs or []: files = config_blobs.setdefault('files', {}) c_name = f'{ext_cluster.alias}.ceph.conf' From 362580714d20ced87485ee16efc07b6ba0038118 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 8 May 2026 11:03:06 +0530 Subject: [PATCH 312/596] mgr/smb: Add is_feature_enabled function to resource class This funality added to find if feature like remote_control and keybride is enable. Signed-off-by: Rabinarayan Panigrahi --- src/pybind/mgr/smb/resources.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pybind/mgr/smb/resources.py b/src/pybind/mgr/smb/resources.py index 0128b6346d2..2956703e68e 100644 --- a/src/pybind/mgr/smb/resources.py +++ b/src/pybind/mgr/smb/resources.py @@ -19,6 +19,7 @@ from ceph.smb.constants import ( BURST_MULT_MIN, BYTES_LIMIT_MAX, IOPS_LIMIT_MAX, + KEYBRIDGE, REMOTE_CONTROL, REMOTE_CONTROL_LOCAL, ) @@ -989,6 +990,15 @@ class Cluster(_RBase): return False return self.keybridge.is_enabled + def is_feature_enabled(self, feature: str) -> bool: + """Return true if the specified SMB feature is enabled for this + cluster. + """ + return { + REMOTE_CONTROL: self.remote_control_is_enabled, + KEYBRIDGE: self.keybridge_is_enabled, + }[feature] + def is_clustered(self) -> bool: """Return true if smbd instance should use (CTDB) clustering.""" if self.clustering_mode == SMBClustering.ALWAYS: From 66fc26d43b77d8ed72186722b5e4f69447536364 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 8 May 2026 11:04:39 +0530 Subject: [PATCH 313/596] mgr/smb: Add ssl_certificates buffer for smb features Add ssl_certificates buffer for smb features like remote_control and keybridge. when certificate applied it stores as a feature name and SSLParameters as value where SSLParameters holds cert, key and ca-cert. Signed-off-by: Rabinarayan Panigrahi Signed-off-by: Avan Thakkar --- src/pybind/mgr/smb/handler.py | 43 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/pybind/mgr/smb/handler.py b/src/pybind/mgr/smb/handler.py index 6f6954d0b2e..10364024181 100644 --- a/src/pybind/mgr/smb/handler.py +++ b/src/pybind/mgr/smb/handler.py @@ -20,7 +20,11 @@ import logging import time import ceph.smb.constants -from ceph.deployment.service_spec import SMBExternalCephCluster, SMBSpec +from ceph.deployment.service_spec import ( + SMBExternalCephCluster, + SMBSpec, + SSLParameters, +) from ceph.fs.earmarking import EarmarkTopScope from . import config_store, external, resources @@ -631,6 +635,7 @@ class ClusterConfigHandler: change_group.cluster.cluster_id, ) cluster = change_group.cluster + ssl_certificates: Dict[str, SSLParameters] = {} assert isinstance(cluster, resources.Cluster) # vols: hold the cephfs volumes our shares touch. some operations are # disabled/skipped unless we touch volumes. @@ -688,6 +693,7 @@ class ClusterConfigHandler: if change_group.ext_ceph_clusters: assert len(change_group.ext_ceph_clusters) == 1 ext_ceph_cluster = change_group.ext_ceph_clusters[0] + smb_spec = _generate_smb_service_spec( cluster, config_entries=config_entries, @@ -697,6 +703,7 @@ class ClusterConfigHandler: data_entity=cluster_conf.data_entity, needs_proxy=_has_proxied_vfs(change_group), ext_ceph_cluster=ext_ceph_cluster, + ssl_certificates=ssl_certificates, ) _save_pending_spec_backup(self.public_store, change_group, smb_spec) # if orch was ever needed in the past we must "re-orch", but if we have @@ -1058,6 +1065,7 @@ def _generate_smb_service_spec( data_entity: str = '', needs_proxy: bool = False, ext_ceph_cluster: Optional[resources.ExternalCephCluster], + ssl_certificates: Dict[str, SSLParameters], ) -> SMBSpec: features = [] if cluster.auth_mode == AuthMode.ACTIVE_DIRECTORY: @@ -1092,8 +1100,9 @@ def _generate_smb_service_spec( user_entities: Optional[List[str]] = None if data_entity: user_entities = [data_entity] + rc_cert = rc_key = rc_ca_cert = None - if cluster.remote_control_is_enabled: + if cluster.is_feature_enabled(_REMOTE_CONTROL): assert cluster.remote_control rc_cert = _tls_uri( cluster.remote_control.cert, tls_credential_entries @@ -1102,8 +1111,18 @@ def _generate_smb_service_spec( rc_ca_cert = _tls_uri( cluster.remote_control.ca_cert, tls_credential_entries ) + feature = ceph.smb.constants.FEATURE_FILE_NAMES.get(_REMOTE_CONTROL) + if feature is not None: + ssl_certificates[feature] = SSLParameters( + enabled=bool(rc_cert and rc_key), + ssl_cert=rc_cert, + ssl_key=rc_key, + ssl_ca_cert=rc_ca_cert, + certificate_source='uri', + ) + kb_cert = kb_key = kb_ca_cert = None - if cluster.keybridge_is_enabled: + if cluster.is_feature_enabled(_KEYBRIDGE): assert cluster.keybridge # type narrow # TODO: current all KMIP scopes must share the same tls creds # and that sucks. need to update keybridge to fetch cert URIs directly @@ -1122,6 +1141,16 @@ def _generate_smb_service_spec( kb_ca_cert = _tls_uri( kmip_scope.kmip_ca_cert, tls_credential_entries ) + feature = ceph.smb.constants.FEATURE_FILE_NAMES.get(_KEYBRIDGE) + if feature is not None: + ssl_certificates[feature] = SSLParameters( + enabled=bool(kb_cert and kb_key), + ssl_cert=kb_cert, + ssl_key=kb_key, + ssl_ca_cert=kb_ca_cert, + certificate_source='uri', + ) + ceph_cluster_configs = None if ext_ceph_cluster: exo = checked(ext_ceph_cluster.cluster) @@ -1134,7 +1163,6 @@ def _generate_smb_service_spec( key=exo.cephfs_user.key, ) ] - return SMBSpec( service_id=cluster.cluster_id, placement=cluster.placement, @@ -1148,12 +1176,7 @@ def _generate_smb_service_spec( cluster_public_addrs=cluster.service_spec_public_addrs(), custom_ports=cluster.custom_ports, bind_addrs=cluster.service_spec_bind_addrs(), - remote_control_ssl_cert=rc_cert, - remote_control_ssl_key=rc_key, - remote_control_ca_cert=rc_ca_cert, - keybridge_kmip_ssl_cert=kb_cert, - keybridge_kmip_ssl_key=kb_key, - keybridge_kmip_ca_cert=kb_ca_cert, + ssl_certificates=ssl_certificates or None, ceph_cluster_configs=ceph_cluster_configs, tunables=_service_spec_tunables(cluster), ) From 38105f70153160b05d9fb542ea33f5fedc8fdb3a Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 8 May 2026 13:08:22 +0530 Subject: [PATCH 314/596] mgr/cephadm: Test case are updated to validate for ssl certificate for smb services Signed-off-by: Rabinarayan Panigrahi Signed-off-by: Avan Thakkar --- src/pybind/mgr/cephadm/tests/test_certmgr.py | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/pybind/mgr/cephadm/tests/test_certmgr.py b/src/pybind/mgr/cephadm/tests/test_certmgr.py index e6eb1db2262..b76fa174b54 100644 --- a/src/pybind/mgr/cephadm/tests/test_certmgr.py +++ b/src/pybind/mgr/cephadm/tests/test_certmgr.py @@ -307,6 +307,8 @@ class TestCertMgr(object): grafana_cert_host_2 = 'grafana-cert-host-2' nfs_ssl_cert = 'nfs-ssl-cert' nfs_ssl_ca_cert = 'nfs-ssl-ca-cert' + smb_ssl_cert = 'smb-ssl-cert' + smb_ssl_ca_cert = 'smb-ssl-ca-cert' cephadm_module.cert_mgr.save_cert('rgw_ssl_cert', rgw_frontend_rgw_foo_host2_cert, service_name='rgw.foo', user_made=True) cephadm_module.cert_mgr.save_cert('nvmeof_ssl_cert', nvmeof_ssl_cert, service_name='nvmeof.self-signed.foo', user_made=False) cephadm_module.cert_mgr.save_cert('nvmeof_client_cert', nvmeof_client_cert, service_name='nvmeof.foo', user_made=True) @@ -315,6 +317,8 @@ class TestCertMgr(object): cephadm_module.cert_mgr.save_cert('grafana_ssl_cert', grafana_cert_host_2, host='host-2', user_made=True) cephadm_module.cert_mgr.save_cert('nfs_ssl_cert', nfs_ssl_cert, service_name='nfs.foo', user_made=True) cephadm_module.cert_mgr.save_cert('nfs_ssl_ca_cert', nfs_ssl_ca_cert, service_name='nfs.foo', user_made=True) + cephadm_module.cert_mgr.save_cert('smb_ssl_cert', smb_ssl_cert, service_name='smb.foo', user_made=True) + cephadm_module.cert_mgr.save_cert('smb_ssl_ca_cert', smb_ssl_ca_cert, service_name='smb.foo', user_made=True) expected_calls = [ mock.call(f'{TLSOBJECT_STORE_CERT_PREFIX}rgw_ssl_cert', json.dumps({'rgw.foo': Cert(rgw_frontend_rgw_foo_host2_cert, True).to_json()})), @@ -326,6 +330,8 @@ class TestCertMgr(object): 'host-2': Cert(grafana_cert_host_2, True).to_json()})), mock.call(f'{TLSOBJECT_STORE_CERT_PREFIX}nfs_ssl_cert', json.dumps({'nfs.foo': Cert(nfs_ssl_cert, True).to_json()})), mock.call(f'{TLSOBJECT_STORE_CERT_PREFIX}nfs_ssl_ca_cert', json.dumps({'nfs.foo': Cert(nfs_ssl_ca_cert, True).to_json()})), + mock.call(f'{TLSOBJECT_STORE_CERT_PREFIX}smb_ssl_cert', json.dumps({'smb.foo': Cert(smb_ssl_cert, True).to_json()})), + mock.call(f'{TLSOBJECT_STORE_CERT_PREFIX}smb_ssl_ca_cert', json.dumps({'smb.foo': Cert(smb_ssl_ca_cert, True).to_json()})), ] _set_store.assert_has_calls(expected_calls) @@ -448,6 +454,24 @@ class TestCertMgr(object): } compare_certls_dicts(expected_ls) + cephadm_module.cert_mgr.save_cert('smb_ssl_cert', CEPHADM_SELF_GENERATED_CERT_1, service_name='smb.foo', user_made=True) + expected_ls["smb_ssl_cert"] = { + "scope": "service", + "certificates": { + "smb.foo": get_generated_cephadm_cert_info_1(), + }, + } + compare_certls_dicts(expected_ls) + + cephadm_module.cert_mgr.save_cert('smb_ssl_ca_cert', CEPHADM_SELF_GENERATED_CERT_2, service_name='smb.foo', user_made=True) + expected_ls["smb_ssl_ca_cert"] = { + "scope": "service", + "certificates": { + "smb.foo": get_generated_cephadm_cert_info_2(), + }, + } + compare_certls_dicts(expected_ls) + # Services with host target/scope cephadm_module.cert_mgr.save_cert('grafana_ssl_cert', CEPHADM_SELF_GENERATED_CERT_1, host='host1', user_made=True) cephadm_module.cert_mgr.save_cert('grafana_ssl_cert', CEPHADM_SELF_GENERATED_CERT_2, host='host2', user_made=True) @@ -612,6 +636,8 @@ class TestCertMgr(object): 'mgmt_gateway_ssl_cert': ('mgmt-gateway', 'mgmt-gw-cert', TLSObjectScope.GLOBAL), 'nfs_ssl_cert': ('nfs.foo', 'nfs-ssl-cert', TLSObjectScope.SERVICE), 'nfs_ssl_ca_cert': ('nfs.foo', 'nfs-ssl-ca-cert', TLSObjectScope.SERVICE), + 'smb_ssl_cert': ('smb.foo', 'smb-ssl-cert', TLSObjectScope.SERVICE), + 'smb_ssl_ca_cert': ('smb.foo', 'smb-ssl-ca-cert', TLSObjectScope.SERVICE), } unknown_certs = { 'unknown_per_service_cert': ('unknown-svc.foo', 'unknown-cert', TLSObjectScope.SERVICE), @@ -629,6 +655,7 @@ class TestCertMgr(object): 'ingress_ssl_key': ('ingress', 'ingress-ssl-key', TLSObjectScope.SERVICE), 'iscsi_ssl_key': ('iscsi', 'iscsi-ssl-key', TLSObjectScope.SERVICE), 'nfs_ssl_key': ('nfs.foo', 'nfs-ssl-key', TLSObjectScope.SERVICE), + 'smb_ssl_key': ('smb.foo', 'smb-ssl-key', TLSObjectScope.SERVICE), } unknown_keys = { 'unknown_per_service_key': ('unknown-svc.foo', 'unknown-key', TLSObjectScope.SERVICE), @@ -703,10 +730,13 @@ class TestCertMgr(object): 'mgmt_gateway_ssl_cert': ('mgmt-gateway', 'good-global-cert', TLSObjectScope.GLOBAL), 'nfs_ssl_cert': ('nfs.foo', 'nfs-ssl-cert', TLSObjectScope.SERVICE), 'nfs_ssl_ca_cert': ('nfs.foo', 'nfs-ssl-ca-cert', TLSObjectScope.SERVICE), + 'smb_ssl_cert': ('smb.foo', 'smb-ssl-cert', TLSObjectScope.SERVICE), + 'smb_ssl_ca_cert': ('smb.foo', 'smb-ssl-ca-cert', TLSObjectScope.SERVICE), } good_keys = { 'rgw_ssl_key': ('rgw.foo', 'good-key', TLSObjectScope.SERVICE), 'nfs_ssl_key': ('nfs.foo', 'nfs-ssl-key', TLSObjectScope.SERVICE), + 'smb_ssl_key': ('smb.foo', 'smb-ssl-key', TLSObjectScope.SERVICE), } # Helpers to dump valid JSON structures @@ -757,12 +787,18 @@ class TestCertMgr(object): assert cert_store['nfs_ssl_cert']['nfs.foo'] == Cert('nfs-ssl-cert', True) assert 'nfs_ssl_ca_cert' in cert_store assert cert_store['nfs_ssl_ca_cert']['nfs.foo'] == Cert('nfs-ssl-ca-cert', True) + assert 'smb_ssl_cert' in cert_store + assert cert_store['smb_ssl_cert']['smb.foo'] == Cert('smb-ssl-cert', True) + assert 'smb_ssl_ca_cert' in cert_store + assert cert_store['smb_ssl_ca_cert']['smb.foo'] == Cert('smb-ssl-ca-cert', True) assert 'mgmt_gateway_ssl_cert' in cert_store assert cert_store['mgmt_gateway_ssl_cert'] == Cert('good-global-cert', True) assert 'rgw_ssl_key' in key_store assert key_store['rgw_ssl_key']['rgw.foo'] == PrivKey('good-key') assert 'nfs_ssl_key' in key_store assert key_store['nfs_ssl_key']['nfs.foo'] == PrivKey('nfs-ssl-key') + assert 'smb_ssl_key' in key_store + assert key_store['smb_ssl_key']['smb.foo'] == PrivKey('smb-ssl-key') # Bad ones: object names exist (pre-registered), but **no targets** were added # Service / Host scoped => dict should be empty From 035b96985ead64f158d9df550378591cb490dfff Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Fri, 8 May 2026 18:08:52 +0530 Subject: [PATCH 315/596] mgr/cephadm: Add test case from smb spec and validating functionality with test Here we have added a test case to test_smb.py to validate functionality of certificate manager after apply certificates. Signed-off-by: Rabinarayan Panigrahi Signed-off-by: Avan Thakkar --- .../mgr/cephadm/tests/services/test_smb.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/pybind/mgr/cephadm/tests/services/test_smb.py b/src/pybind/mgr/cephadm/tests/services/test_smb.py index f6da6f557ab..2add9e5fe6e 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_smb.py +++ b/src/pybind/mgr/cephadm/tests/services/test_smb.py @@ -1,13 +1,23 @@ import json from unittest.mock import patch +from ceph.smb.constants import REMOTE_CONTROL from cephadm.services.smb import SMBSpec, SMBExternalCephCluster from cephadm.module import CephadmOrchestrator from cephadm.tests.fixtures import with_host, with_service, async_side_effect +from cephadm.services.service_registry import service_registry +from cephadm.services.cephadmservice import CephadmDaemonDeploySpec +from ceph.deployment.service_spec import PlacementSpec _SAMBA_METRICS_IMAGE = 'quay.io/samba.org/samba-metrics:devbuilds-centos-any' +cephadm_root_ca = """-----BEGIN CERTIFICATE-----\nMIIE7DCCAtSgAwIBAgIUE8b2zZ64geu2ns3Zfn3/4L+Cf6MwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMY2VwaGFkbS1yb290MB4XDTI0MDYyNjE0NDA1M1oXDTM0\nMDYyNzE0NDA1M1owFzEVMBMGA1UEAwwMY2VwaGFkbS1yb290MIICIjANBgkqhkiG\n9w0BAQEFAAOCAg8AMIICCgKCAgEAsZRJsdtTr9GLG1lWFql5SGc46ldFanNJd1Gl\nqXq5vgZVKRDTmNgAb/XFuNEEmbDAXYIRZolZeYKMHfn0pouPRSel0OsC6/02ZUOW\nIuN89Wgo3IYleCFpkVIumD8URP3hwdu85plRxYZTtlruBaTRH38lssyCqxaOdEt7\nAUhvYhcMPJThB17eOSQ73mb8JEC83vB47fosI7IhZuvXvRSuZwUW30rJanWNhyZq\neS2B8qw2RSO0+77H6gA4ftBnitfsE1Y8/F9Z/f92JOZuSMQXUB07msznPbRJia3f\nueO8gOc32vxd1A1/Qzp14uX34yEGY9ko2lW226cZO29IVUtXOX+LueQttwtdlpz8\ne6Npm09pXhXAHxV/OW3M28MdXmobIqT/m9MfkeAErt5guUeC5y8doz6/3VQRjFEn\nRpN0WkblgnNAQ3DONPc+Qd9Fi/wZV2X7bXoYpNdoWDsEOiE/eLmhG1A2GqU/mneP\nzQ6u79nbdwTYpwqHpa+PvusXeLfKauzI8lLUJotdXy9EK8iHUofibB61OljYye6B\nG3b8C4QfGsw8cDb4APZd/6AZYyMx/V3cGZ+GcOV7WvsC8k7yx5Uqasm/kiGQ3EZo\nuNenNEYoGYrjb8D/8QzqNUTwlEh27/ps80tO7l2GGTvWVZL0PRZbmLDvO77amtOf\nOiRXMoUCAwEAAaMwMC4wGwYDVR0RBBQwEocQAAAAAAAAAAAAAAAAAAAAATAPBgNV\nHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAxwzX5AhYEWhTV4VUwUj5+\nqPdl4Q2tIxRokqyE+cDxoSd+6JfGUefUbNyBxDt0HaBq8obDqqrbcytxnn7mpnDu\nhtiauY+I4Amt7hqFOiFA4cCLi2mfok6g2vL53tvhd9IrsfflAU2wy7hL76Ejm5El\nA+nXlkJwps01Whl9pBkUvIbOn3pXX50LT4hb5zN0PSu957rjd2xb4HdfuySm6nW4\n4GxtVWfmGA6zbC4XMEwvkuhZ7kD2qjkAguGDF01uMglkrkCJT3OROlNBuSTSBGqt\ntntp5VytHvb7KTF7GttM3ha8/EU2KYaHM6WImQQTrOfiImAktOk4B3lzUZX3HYIx\n+sByO4P4dCvAoGz1nlWYB2AvCOGbKf0Tgrh4t4jkiF8FHTXGdfvWmjgi1pddCNAy\nn65WOCmVmLZPERAHOk1oBwqyReSvgoCFo8FxbZcNxJdlhM0Z6hzKggm3O3Dl88Xl\n5euqJjh2STkBW8Xuowkg1TOs5XyWvKoDFAUzyzeLOL8YSG+gXV22gPTUaPSVAqdb\nwd0Fx2kjConuC5bgTzQHs8XWA930U3XWZraj21Vaa8UxlBLH4fUro8H5lMSYlZNE\nJHRNW8BkznAClaFSDG3dybLsrzrBFAu/Qb5zVkT1xyq0YkepGB7leXwq6vjWA5Pw\nmZbKSphWfh0qipoqxqhfkw==\n-----END CERTIFICATE-----\n""" + +ceph_generated_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQDIZSujNBlKaLJzmvntjukjMA0GCSqGSIb3DQEBDQUAMCExDTAL\nBgNVBAoMBENlcGgxEDAOBgNVBAMMB2NlcGhhZG0wHhcNMjIwNzEzMTE0NzA3WhcN\nMzIwNzEwMTE0NzA3WjAhMQ0wCwYDVQQKDARDZXBoMRAwDgYDVQQDDAdjZXBoYWRt\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyyMe4DMA+MeYK7BHZMHB\nq7zjliEOcNgxomjU8qbf5USF7Mqrf6+/87XWqj4pCyAW8x0WXEr6A56a+cmBVmt+\nqtWDzl020aoId6lL5EgLLn6/kMDCCJLq++Lg9cEofMSvcZh+lY2f+1p+C+00xent\nrLXvXGOilAZWaQfojT2BpRnNWWIFbpFwlcKrlg2G0cFjV5c1m6a0wpsQ9JHOieq0\nSvwCixajwq3CwAYuuiU1wjI4oJO4Io1+g8yB3nH2Mo/25SApCxMXuXh4kHLQr/T4\n4hqisvG4uJYgKMcSIrWj5o25mclByGi1UI/kZkCUES94i7Z/3ihx4Bad0AMs/9tw\nFwIDAQABMA0GCSqGSIb3DQEBDQUAA4IBAQAf+pwz7Gd7mDwU2LY0TQXsK6/8KGzh\nHuX+ErOb8h5cOAbvCnHjyJFWf6gCITG98k9nxU9NToG0WYuNm/max1y/54f0dtxZ\npUo6KSNl3w6iYCfGOeUIj8isi06xMmeTgMNzv8DYhDt+P2igN6LenqWTVztogkiV\nxQ5ZJFFLEw4sN0CXnrZX3t5ruakxLXLTLKeE0I91YJvjClSBGkVJq26wOKQNHMhx\npWxeydQ5EgPZY+Aviz5Dnxe8aB7oSSovpXByzxURSabOuCK21awW5WJCGNpmqhWK\nZzACBDEstccj57c4OGV0eayHJRsluVr2e9NHRINZA3qdB37e6gsI1xHo\n-----END CERTIFICATE-----\n""" + +ceph_generated_key = """-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDLIx7gMwD4x5gr\nsEdkwcGrvOOWIQ5w2DGiaNTypt/lRIXsyqt/r7/ztdaqPikLIBbzHRZcSvoDnpr5\nyYFWa36q1YPOXTbRqgh3qUvkSAsufr+QwMIIkur74uD1wSh8xK9xmH6VjZ/7Wn4L\n7TTF6e2ste9cY6KUBlZpB+iNPYGlGc1ZYgVukXCVwquWDYbRwWNXlzWbprTCmxD0\nkc6J6rRK/AKLFqPCrcLABi66JTXCMjigk7gijX6DzIHecfYyj/blICkLExe5eHiQ\nctCv9PjiGqKy8bi4liAoxxIitaPmjbmZyUHIaLVQj+RmQJQRL3iLtn/eKHHgFp3Q\nAyz/23AXAgMBAAECggEAVoTB3Mm8azlPlaQB9GcV3tiXslSn+uYJ1duCf0sV52dV\nBzKW8s5fGiTjpiTNhGCJhchowqxoaew+o47wmGc2TvqbpeRLuecKrjScD0GkCYyQ\neM2wlshEbz4FhIZdgS6gbuh9WaM1dW/oaZoBNR5aTYo7xYTmNNeyLA/jO2zr7+4W\n5yES1lMSBXpKk7bDGKYY4bsX2b5RLr2Grh2u2bp7hoLABCEvuu8tSQdWXLEXWpXo\njwmV3hc6tabypIa0mj2Dmn2Dmt1ppSO0AZWG/WAizN3f4Z0r/u9HnbVrVmh0IEDw\n3uf2LP5o3msG9qKCbzv3lMgt9mMr70HOKnJ8ohMSKQKBgQDLkNb+0nr152HU9AeJ\nvdz8BeMxcwxCG77iwZphZ1HprmYKvvXgedqWtS6FRU+nV6UuQoPUbQxJBQzrN1Qv\nwKSlOAPCrTJgNgF/RbfxZTrIgCPuK2KM8I89VZv92TSGi362oQA4MazXC8RAWjoJ\nSu1/PHzK3aXOfVNSLrOWvIYeZQKBgQD/dgT6RUXKg0UhmXj7ExevV+c7oOJTDlMl\nvLngrmbjRgPO9VxLnZQGdyaBJeRngU/UXfNgajT/MU8B5fSKInnTMawv/tW7634B\nw3v6n5kNIMIjJmENRsXBVMllDTkT9S7ApV+VoGnXRccbTiDapBThSGd0wri/CuwK\nNWK1YFOeywKBgEDyI/XG114PBUJ43NLQVWm+wx5qszWAPqV/2S5MVXD1qC6zgCSv\nG9NLWN1CIMimCNg6dm7Wn73IM7fzvhNCJgVkWqbItTLG6DFf3/DPODLx1wTMqLOI\nqFqMLqmNm9l1Nec0dKp5BsjRQzq4zp1aX21hsfrTPmwjxeqJZdioqy2VAoGAXR5X\nCCdSHlSlUW8RE2xNOOQw7KJjfWT+WAYoN0c7R+MQplL31rRU7dpm1bLLRBN11vJ8\nMYvlT5RYuVdqQSP6BkrX+hLJNBvOLbRlL+EXOBrVyVxHCkDe+u7+DnC4epbn+N8P\nLYpwqkDMKB7diPVAizIKTBxinXjMu5fkKDs5n+sCgYBbZheYKk5M0sIxiDfZuXGB\nkf4mJdEkTI1KUGRdCwO/O7hXbroGoUVJTwqBLi1tKqLLarwCITje2T200BYOzj82\nqwRkCXGtXPKnxYEEUOiFx9OeDrzsZV00cxsEnX0Zdj+PucQ/J3Cvd0dWUspJfLHJ\n39gnaegswnz9KMQAvzKFdg==\n-----END PRIVATE KEY-----\n""" + class TestSMB: @patch("cephadm.module.CephadmOrchestrator.get_unique_name") @@ -149,6 +159,49 @@ class TestSMB: use_current_daemon_image=False, ) + @patch("cephadm.serve.CephadmServe._run_cephadm") + def test_smb_tls_feature_cert_in_config_blobs( + self, _run_cephadm, cephadm_module: CephadmOrchestrator + ): + _run_cephadm.side_effect = async_side_effect(('{}', '', 0)) + + with with_host(cephadm_module, 'test', addr='1.2.3.7'): + cephadm_module.cache.update_host_networks( + 'test', + {'1.2.3.0/24': {'if0': ['1.2.3.7']}} + ) + + smb_spec = SMBSpec( + cluster_id='foxtrot', + service_id='foo', + config_uri='rados://.smb/foxtrot/config2.json', + placement=PlacementSpec(hosts=['test']), + features=[REMOTE_CONTROL], + ssl_certificates={ + 'remote_control': { + 'enabled': True, + 'ssl_cert': ceph_generated_cert, + 'ssl_key': ceph_generated_key, + 'ssl_ca_cert': cephadm_root_ca, + 'certificate_source': 'inline', + }, + }, + ) + service_name = smb_spec.service_name() + + with with_service(cephadm_module, smb_spec): + smb_conf, _ = service_registry.get_service('smb').generate_config( + CephadmDaemonDeploySpec( + host='test', + daemon_id='foo.test.0', + service_name=service_name, + ) + ) + files = smb_conf.get('files', {}) + assert files.get('remote_control.ssl.crt') == ceph_generated_cert + assert files.get('remote_control.ssl.key') == ceph_generated_key + assert files.get('remote_control.ca.crt') == cephadm_root_ca + def test_smb_get_dependencies(cephadm_module): from cephadm.services.smb import SMBService From 33c9e66cea3be9225b7462dde85f456909d48199 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Sun, 10 May 2026 13:01:22 +0530 Subject: [PATCH 316/596] doc/cephadm: Add SMB TLS/SSL configuration and examples Add SMB TLS/SSL configuration with example for SMB features remote_control and keybridge Signed-off-by: Rabinarayan Panigrahi --- doc/cephadm/services/smb.rst | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/doc/cephadm/services/smb.rst b/doc/cephadm/services/smb.rst index ea7ae632f9e..bc5b7c7cfdf 100644 --- a/doc/cephadm/services/smb.rst +++ b/doc/cephadm/services/smb.rst @@ -53,13 +53,100 @@ An SMB service can be applied using a specification. An example in YAML follows: include_ceph_users: - client.smb.fs.cluster.tango +TLS/SSL Example +--------------- + +Here's an example SMB service specification with TLS/SSL configuration: + +.. code-block:: yaml + + service_id: smbcluster + service_type: smb + cluster_id: tango + config_uri: rados://smb/foxtrot/config.json + placement: + hosts: + - host0 + spec: + ssl_certificates: + remote_control: + enabled: true + certificate_source: inline + ssl_cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + + ssl_key: | + -----BEGIN PRIVATE KEY----- + ... + -----END PRIVATE KEY----- + + ssl_ca_cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + keybridge: + enabled: true + certificate_source: inline + ssl_cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + + ssl_key: | + -----BEGIN PRIVATE KEY----- + ... + -----END PRIVATE KEY----- + + ssl_ca_cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + +This example configures an SMB service with TLS encryption enabled using +inline certificates. + +TLS/SSL Parameters +~~~~~~~~~~~~~~~~~~ + +The following parameters can be used to configure TLS/SSL encryption per sidecar +for the SMB service: + +* ``enabled`` (boolean): Enable or disable SSL/TLS encryption. Default is ``false``. + +* ``certificate_source`` (string): Specifies the source of the TLS certificates. + Options include: + + - ``cephadm-signed``: Use certificates signed by cephadm's internal CA + - ``inline``: Provide certificates directly in the specification using ``ssl_cert``, + ``ssl_key`` and ``ssl_ca_cert`` fields + - ``reference``: Users can register their own certificate and key with certmgr and + set the ``certificate_source`` to ``reference`` in the spec. + +* ``ssl_cert`` (string): The SSL certificate in PEM format. Required when using + ``inline`` certificate source. + +* ``ssl_key`` (string): The SSL private key in PEM format. Required when using + ``inline`` certificate source. + +* ``ssl_ca_cert`` (string): The SSL CA certificate in PEM format. Required when + using ``inline`` certificate source. + +.. note:: + ``ssl_key``, ``ssl_cert`` and ``ssl_ca_cert`` can be set from the smb manager + module. If ``cert`` and ``key`` are specified in the resource_type + ``ceph.smb.tls.credential`` and applied from the smb manager will be automatically + configured as ssl_certificate is enabled and update ``ssl_key``, ``ssl_cert`` to + the certificate manager. ``ssl_ca_cert`` will be set if it is specified in the + resource_type ``ceph.smb.tls.credential`` + The specification can then be applied by running the following command: .. prompt:: bash # ceph orch apply -i smb.yaml - Service Spec Options -------------------- From a3aa69398767f9c3db26d18b7bc298300a52c9b8 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Sun, 31 May 2026 22:09:09 +0530 Subject: [PATCH 317/596] mgr/cephadm: add ca_cert_required parameter to get_certificates mock in test Add the ca_cert_required parameter to the lambda function mocking CephadmService.get_certificates in test_prometheus_config_security_enabled to match the updated method signature. This ensures the test mock properly handles the new parameter that was added to the get_certificates method. Signed-off-by: Rabinarayan Panigrahi --- src/pybind/mgr/cephadm/tests/services/test_monitoring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py index 6c23592dd78..340a231d2e3 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_monitoring.py +++ b/src/pybind/mgr/cephadm/tests/services/test_monitoring.py @@ -793,7 +793,7 @@ class TestMonitoring: @patch("cephadm.services.monitoring.password_hash", lambda password: 'prometheus_password_hash') @patch('cephadm.cert_mgr.CertMgr.get_root_ca', lambda instance: 'cephadm_root_cert') @patch("cephadm.services.cephadmservice.CephadmService.get_certificates", - lambda instance, dspec, ips=None, fqdns=None: TLSCredentials('mycert', 'mykey')) + lambda instance, dspec, ips=None, fqdns=None, ca_cert_required=False: TLSCredentials('mycert', 'mykey')) def test_prometheus_config_security_enabled(self, _run_cephadm, _get_uname, cephadm_module: CephadmOrchestrator): _run_cephadm.side_effect = async_side_effect(('{}', '', 0)) _get_uname.return_value = 'test' From d74b88500b654774ba05339f4ee7c2d673af4f55 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 18 Jun 2026 22:06:36 +0800 Subject: [PATCH 318/596] debian/rules: exclude CI and test directories from mgr plugin packages The ceph-mgr-dashboard and ceph-mgr-rook packages install their entire mgr module directory, which includes a ci/ subdirectory containing Dockerfiles, e2e test scripts, and cluster specs used only for upstream CI pipelines. ceph-mgr-cephadm similarly ships a tests/ directory with Python unit tests. These files have no runtime purpose on a deployed system and should not be shipped in the binary packages. Exclude mgr/cephadm/tests, mgr/dashboard/ci, and mgr/rook/ci via dh_install --exclude. Signed-off-by: Kefu Chai --- debian/rules | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/debian/rules b/debian/rules index 48b8634a1c4..d27dac6a664 100755 --- a/debian/rules +++ b/debian/rules @@ -158,7 +158,10 @@ override_dh_python3: dh_python3 -p ceph-fuse --shebang=/usr/bin/python3 dh_python3 -p ceph-volume --shebang=/usr/bin/python3 +override_dh_install: + dh_install --exclude=mgr/cephadm/tests --exclude=mgr/dashboard/ci --exclude=mgr/rook/ci + # do not run tests override_dh_auto_test: -.PHONY: override_dh_autoreconf override_dh_auto_configure override_dh_auto_clean override_dh_auto_install override_dh_installlogrotate override_dh_installinit override_dh_strip override_dh_auto_test +.PHONY: override_dh_autoreconf override_dh_auto_configure override_dh_auto_clean override_dh_auto_install override_dh_installlogrotate override_dh_installinit override_dh_install override_dh_strip override_dh_auto_test From 8a2e0eb2fcd12abf4917c74dcd39f6e7f25e5e75 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Thu, 18 Jun 2026 22:06:43 +0800 Subject: [PATCH 319/596] ceph.spec.in: exclude CI and test directories from mgr plugin packages The ceph-mgr-dashboard and ceph-mgr-rook packages install their entire mgr module directory, which includes a ci/ subdirectory containing Dockerfiles, e2e test scripts, and cluster specs used only for upstream CI pipelines. ceph-mgr-cephadm similarly ships a tests/ directory with Python unit tests. These files have no runtime purpose on a deployed system and should not be shipped in the binary packages. Exclude mgr/cephadm/tests, mgr/dashboard/ci, and mgr/rook/ci via %exclude directives. Signed-off-by: Kefu Chai --- ceph.spec.in | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ceph.spec.in b/ceph.spec.in index b0fba106c2c..186cacb9a86 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -2345,6 +2345,7 @@ fi %files mgr-dashboard %{_datadir}/ceph/mgr/dashboard +%exclude %{_datadir}/ceph/mgr/dashboard/ci %ceph_mgr_module_scripts mgr-dashboard @@ -2465,6 +2466,7 @@ fi %files mgr-rook %{_datadir}/ceph/mgr/rook +%exclude %{_datadir}/ceph/mgr/rook/ci %ceph_mgr_module_scripts mgr-rook @@ -2475,6 +2477,7 @@ fi %files mgr-cephadm %{_datadir}/ceph/mgr/cephadm +%exclude %{_datadir}/ceph/mgr/cephadm/tests %ceph_mgr_module_scripts mgr-cephadm From 8a35d17c7207a530a4e76b52468fc606849024c3 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 29 May 2026 16:35:26 +0800 Subject: [PATCH 320/596] librbd/cache/pwl: cancel periodic_stats timer before perf_stop() AbstractWriteLog::shut_down() calls perf_stop(), which deletes m_perfcounter, but the 5s periodic_stats timer is only canceled later in the destructor. If it fires in between, periodic_stats -> update_image_cache_state dereferences the freed m_perfcounter. Cancel the timer under m_timer_lock first. Destroying an AbstractWriteLog that was init()'ed without a matching shut_down() is illegal, so the cancel_event() in the destructor is now redundant and is dropped. Fixes: https://tracker.ceph.com/issues/77501 Signed-off-by: Sun Yuechi --- src/librbd/cache/pwl/AbstractWriteLog.cc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/librbd/cache/pwl/AbstractWriteLog.cc b/src/librbd/cache/pwl/AbstractWriteLog.cc index 70d8d7418a1..44ea8271b50 100644 --- a/src/librbd/cache/pwl/AbstractWriteLog.cc +++ b/src/librbd/cache/pwl/AbstractWriteLog.cc @@ -85,9 +85,7 @@ template AbstractWriteLog::~AbstractWriteLog() { ldout(m_image_ctx.cct, 15) << "enter" << dendl; { - std::lock_guard timer_locker(*m_timer_lock); std::lock_guard locker(m_lock); - m_timer->cancel_event(m_timer_ctx); m_thread_pool.stop(); ceph_assert(m_deferred_ios.size() == 0); ceph_assert(m_ops_to_flush.size() == 0); @@ -649,6 +647,11 @@ void AbstractWriteLog::shut_down(Context *on_finish) { Context *ctx = new LambdaContext( [this, on_finish](int r) { + { + std::lock_guard timer_locker(*m_timer_lock); + m_timer->cancel_event(m_timer_ctx); + m_timer_ctx = nullptr; + } if (m_perfcounter) { perf_stop(); } From dbf322147969fe0e7fe2060fcfec7500fc5b4712 Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Sun, 31 May 2026 23:02:11 +0530 Subject: [PATCH 321/596] mgr/cephadm/services: Add supported function for ssl certificates Added function for get smb features TLSCredentials using _get_feature_certs Added function _get_certificates_from_spec_ssl_certificates to get the certificates for ssl_certificates Updating features ssl certificates to default path of containers Signed-off-by: Rabinarayan Panigrahi Signed-off-by: Avan Thakkar --- src/pybind/mgr/cephadm/services/smb.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/pybind/mgr/cephadm/services/smb.py b/src/pybind/mgr/cephadm/services/smb.py index 831b89dc1ef..d6f31c8312a 100644 --- a/src/pybind/mgr/cephadm/services/smb.py +++ b/src/pybind/mgr/cephadm/services/smb.py @@ -16,11 +16,8 @@ from typing import ( ) from mgr_module import HandleCommandResult -from cephadm.tlsobject_types import TLSObjectScope, TLSCredentials, EMPTY_TLS_CREDENTIALS from ceph.smb.constants import ( - KEYBRIDGE, - REMOTE_CONTROL, - FEATURES, + SMB_FEATURE_SUPPORTS_SSL, ) from ceph.deployment.service_spec import ( @@ -152,21 +149,15 @@ class SMBService(CephService): ) return daemon_spec - def _feature_tls_filename(self, feature: str) -> Optional[str]: - return { - KEYBRIDGE: "keybridge", - REMOTE_CONTROL: "remote_control" - }.get(feature) - # Flat SSL fields on SMBSpec per feature, used as a fallback when # ssl_certificates is not set (backward compatibility). _FEATURE_FLAT_ATTRS: Dict[str, Tuple[str, str, str]] = { - REMOTE_CONTROL: ( + "remote_control": ( 'remote_control_ssl_cert', 'remote_control_ssl_key', 'remote_control_ca_cert', ), - KEYBRIDGE: ( + "keybridge": ( 'keybridge_kmip_ssl_cert', 'keybridge_kmip_ssl_key', 'keybridge_kmip_ca_cert', @@ -177,7 +168,6 @@ class SMBService(CephService): self, daemon_spec: CephadmDaemonDeploySpec, smb_spec: SMBSpec, - support_feature: str, feature: str, ) -> TLSCredentials: feature_creds = self.get_certificates( @@ -185,7 +175,7 @@ class SMBService(CephService): ) if feature_creds: return feature_creds - flat_attrs = self._FEATURE_FLAT_ATTRS.get(support_feature) + flat_attrs = self._FEATURE_FLAT_ATTRS.get(feature) if flat_attrs is None: return EMPTY_TLS_CREDENTIALS cert_attr, key_attr, ca_attr = flat_attrs @@ -225,7 +215,6 @@ class SMBService(CephService): smb_spec = cast( SMBSpec, self.mgr.spec_store[daemon_spec.service_name].spec ) - ssl_certificates = smb_spec.ssl_certificates or {} config_blobs: Dict[str, Any] = {} config_blobs['cluster_id'] = smb_spec.cluster_id @@ -254,11 +243,10 @@ class SMBService(CephService): config_blobs['service_ports'] = smb_spec.service_ports() if smb_spec.bind_addrs: config_blobs['bind_networks'] = smb_spec.bind_networks() - for support_feature in smb_spec.features: - feature = self._feature_tls_filename(support_feature) - if feature is not None: + for feature in smb_spec.ssl_certificates.keys(): + if feature is not None and feature in SMB_FEATURE_SUPPORTS_SSL: feature_creds = self._get_feature_certs( - daemon_spec, smb_spec, support_feature, feature + daemon_spec, smb_spec, feature ) if feature_creds: files = config_blobs.setdefault('files', {}) From 6f598d751326b49898852d640b0731dddcbb1f5a Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Tue, 16 Jun 2026 00:40:58 +0530 Subject: [PATCH 322/596] mgr/cephadm: Registering smb features for ssl certificate with certmgr Here we are registering smb features such remote_control and keybridge with certificate manager and can be list with ceph orch certmgr bindings ls Signed-off-by: Avan Thakkar Signed-off-by: Rabinarayan Panigrahi --- src/pybind/mgr/cephadm/module.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 0798b44fff4..96cb2aee2d0 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -776,6 +776,15 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.cert_mgr.register_cert('nvmeof', 'nvmeof_root_ca_cert', TLSObjectScope.SERVICE) # register haproxy monitor ssl cert and key self.cert_mgr.register_cert_key_pair('ingress', 'haproxy_monitor_ssl_cert', 'haproxy_monitor_ssl_key', TLSObjectScope.SERVICE) + # register per-feature SMB TLS cert names + for _smb_feature in ['remote_control', 'keybridge']: + self.cert_mgr.register_cert_key_pair( + 'smb', + f'smb_{_smb_feature}_ssl_cert', + f'smb_{_smb_feature}_ssl_key', + TLSObjectScope.SERVICE, + f'smb_{_smb_feature}_ca_cert', + ) self.cert_mgr.init_tlsobject_store() From f02b985bd84f4e2a60eed57fc0cd2f8c43ece0cb Mon Sep 17 00:00:00 2001 From: Rabinarayan Panigrahi Date: Thu, 18 Jun 2026 17:15:01 +0530 Subject: [PATCH 323/596] python-common/ceph/smb: Add smb features constants for SSL Add smb constants to support for ca_certificate validation and string alteration Signed-off-by: Rabinarayan Panigrahi --- src/python-common/ceph/smb/constants.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/python-common/ceph/smb/constants.py b/src/python-common/ceph/smb/constants.py index 53745b3a421..f82a0c912f1 100644 --- a/src/python-common/ceph/smb/constants.py +++ b/src/python-common/ceph/smb/constants.py @@ -17,7 +17,6 @@ REMOTE_CONTROL = 'remote-control' REMOTE_CONTROL_LOCAL = 'remote-control-local' SMBMETRICS = 'smbmetrics' - # Features are optional components that can be deployed in a suite of smb # related containers. It may run as a separate sidecar or side-effect the # configuration of another component. @@ -39,6 +38,15 @@ SERVICES = { SMBMETRICS, } +FEATURE_FILE_NAMES = { + KEYBRIDGE: KEYBRIDGE, + REMOTE_CONTROL: "remote_control", +} +SMB_FEATURE_SUPPORTS_SSL = { + "remote_control", + KEYBRIDGE, +} +CA_CERT_REQUIRED_FEATURES = {'keybridge'} # Default port values SMB_PORT = 445 From eee6a15dcd845914595dbb1d76470bd4b73947c1 Mon Sep 17 00:00:00 2001 From: Abhishek Desai Date: Tue, 26 May 2026 13:18:40 +0530 Subject: [PATCH 324/596] mgr/dashboard : Support wildcard sans and zonegroup hostnames fixes : https://tracker.ceph.com/issues/76795 Signed-off-by: Abhishek Desai --- .../service-form/service-form.component.html | 55 +++++++++- .../service-form.component.spec.ts | 58 ++++++++++ .../service-form/service-form.component.ts | 103 ++++++++++++++---- .../app/shared/models/service.interface.ts | 8 ++ 4 files changed, 197 insertions(+), 27 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html index 9cdf6e025bc..c77c36abc14 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -403,6 +403,37 @@ } + + @if (!rgwModuleEnabled) { + + The RGW mgr module must be enabled to configure S3 hostnames. + Enabling the module will cause temporary manager downtime while it loads. + + } +
+ + Enable virtual-host style bucket access + + Allows bucket access via hostnames such as mybucket.s3.example.com. + + +
+ @if (serviceForm.controls.virtual_host_enabled.value) { +
+ + +
+ } } @@ -1218,13 +1249,13 @@ - Internal - External @@ -1239,7 +1270,7 @@ } - @if (serviceForm.controls.certificateType.value === 'internal') { + @if (serviceForm.controls.certificateType.value === CertificateType.internal) { @@ -1253,6 +1284,18 @@ i18n-helperText> + @if (serviceForm.controls.service_type.value === 'rgw' + && serviceForm.controls.virtual_host_enabled.value) { +
+ + Include wildcard certificate for bucket subdomains + + Add wildcard certificates (*.domain) to allow SSL for all bucket subdomains. Required for virtual-host style with SSL. + + +
+ } } } @@ -1288,7 +1331,7 @@ - @if (serviceForm.controls.ssl.value && serviceForm.controls.certificateType.value === 'external' && serviceForm.controls.service_type.value === 'nfs') { + @if (serviceForm.controls.ssl.value && serviceForm.controls.certificateType.value === CertificateType.external && serviceForm.controls.service_type.value === 'nfs') {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts index 6ffd5be14c6..915c320bacc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -263,6 +263,64 @@ describe('ServiceFormComponent', () => { }); }); + it('should submit rgw with virtual-host style bucket access and SSL', () => { + formHelper.setValue('virtual_host_enabled', true); + formHelper.setValue('ssl', true); + formHelper.setValue('zonegroup_hostnames', ['s3.cephlab.com']); + formHelper.setValue('wildcard_enabled', true); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + service_id: 'svc', + rgw_realm: null, + rgw_zone: null, + rgw_zonegroup: null, + placement: {}, + unmanaged: false, + ssl: true, + certificate_source: 'cephadm-signed', + zonegroup_hostnames: ['s3.cephlab.com'], + wildcard_enabled: true + }); + }); + + it('should submit rgw with SSL and without virtual-host style bucket access', () => { + formHelper.setValue('ssl', true); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + service_id: 'svc', + rgw_realm: null, + rgw_zone: null, + rgw_zonegroup: null, + placement: {}, + unmanaged: false, + ssl: true, + certificate_source: 'cephadm-signed' + }); + }); + + it('should submit rgw with virtual-host style bucket access and SSL without wildcard certificate', () => { + formHelper.setValue('virtual_host_enabled', true); + formHelper.setValue('ssl', true); + formHelper.setValue('zonegroup_hostnames', ['s3.cephlab.com']); + formHelper.setValue('wildcard_enabled', false); + component.onSubmit(); + expect(cephServiceService.create).toHaveBeenCalledWith({ + service_type: 'rgw', + service_id: 'svc', + rgw_realm: null, + rgw_zone: null, + rgw_zonegroup: null, + placement: {}, + unmanaged: false, + ssl: true, + certificate_source: 'cephadm-signed', + zonegroup_hostnames: ['s3.cephlab.com'], + wildcard_enabled: false + }); + }); + it('should submit valid rgw port (1)', () => { formHelper.setValue('rgw_frontend_port', 1); component.onSubmit(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index bbde926b6b5..fb5f36af337 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -18,6 +18,7 @@ import { HostService } from '~/app/shared/api/host.service'; import { PoolService } from '~/app/shared/api/pool.service'; import { RbdService } from '~/app/shared/api/rbd.service'; import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; import { RgwRealmService } from '~/app/shared/api/rgw-realm.service'; import { RgwZoneService } from '~/app/shared/api/rgw-zone.service'; import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service'; @@ -37,6 +38,7 @@ import { Host } from '~/app/shared/models/host.interface'; import { CephServiceCertificate, CephServiceSpec, + CertificateType, QatOptions, QatSepcs, CERTIFICATE_STATUS_ICON_MAP @@ -54,6 +56,7 @@ import { TimerService } from '~/app/shared/services/timer.service'; export class ServiceFormComponent extends CdForm implements OnInit { public sub = new Subscription(); + readonly CertificateType = CertificateType; readonly MDS_SVC_ID_PATTERN = /^[a-zA-Z_.-][a-zA-Z0-9_.-]*$/; readonly SNMP_DESTINATION_PATTERN = /^[^\:]+:[0-9]/; readonly SNMP_ENGINE_ID_PATTERN = /^[0-9A-Fa-f]{10,64}/g; @@ -115,6 +118,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { })); showMgmtGatewayMessage: boolean = false; showCertSourceChangeWarning: boolean = false; + rgwModuleEnabled = false; qatCompressionOptions = [ { value: QatOptions.hw, label: 'Hardware' }, { value: QatOptions.sw, label: 'Software' }, @@ -141,6 +145,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { public rgwZonegroupService: RgwZonegroupService, public rgwZoneService: RgwZoneService, public rgwMultisiteService: RgwMultisiteService, + private mgrModuleService: MgrModuleService, private route: ActivatedRoute, public modalService: ModalCdsService, private location: Location @@ -422,7 +427,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'rgw', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.pemCert()] ), @@ -431,7 +436,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'iscsi', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslCert()] ), @@ -440,7 +445,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'ingress', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.pemCert()] ), @@ -449,7 +454,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'oauth2-proxy', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslCert()] ), @@ -458,7 +463,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'mgmt-gateway', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslCert()] ), @@ -467,7 +472,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'nfs', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.pemCert()] ) @@ -481,7 +486,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'iscsi', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslPrivKey()] ), @@ -490,7 +495,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'oauth2-proxy', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslPrivKey()] ), @@ -499,7 +504,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'mgmt-gateway', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslPrivKey()] ), @@ -508,14 +513,17 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'nfs', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.sslPrivKey()] ) ] ], - certificateType: ['internal'], + certificateType: [CertificateType.internal], custom_sans: [null], + virtual_host_enabled: [false], + zonegroup_hostnames: [null], + wildcard_enabled: [true], ssl_ca_cert: [ '', [ @@ -524,7 +532,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { service_type: 'nfs', unmanaged: false, ssl: true, - certificateType: 'external' + certificateType: CertificateType.external }, [Validators.required, CdValidators.pemCert()] ) @@ -686,6 +694,8 @@ export class ServiceFormComponent extends CdForm implements OnInit { this.open = true; this.action = this.actionLabels.CREATE; this.resolveRoute(); + this.getRgwModuleStatus(); + this.mgrModuleService.updateCompleted$.subscribe(() => this.getRgwModuleStatus()); this.cephServiceService .list(new HttpParams({ fromObject: { limit: -1, offset: 0 } })) @@ -792,10 +802,21 @@ export class ServiceFormComponent extends CdForm implements OnInit { response[0].spec?.qat ); this.serviceForm.get('ssl').setValue(response[0].spec?.ssl); + if (response[0].spec?.zonegroup_hostnames?.length) { + this.serviceForm + .get('zonegroup_hostnames') + .setValue(response[0].spec.zonegroup_hostnames); + if (this.rgwModuleEnabled) { + this.serviceForm.get('virtual_host_enabled').setValue(true); + } + } + this.serviceForm + .get('wildcard_enabled') + .setValue(response[0].spec?.wildcard_enabled ?? true); if (response[0].spec?.ssl) { // Special case for rgw: if certificate_source is not cephadm-signed, set certificateType to external if (response[0].spec?.certificate_source != 'cephadm-signed') { - this.serviceForm.get('certificateType').setValue('external'); + this.serviceForm.get('certificateType').setValue(CertificateType.external); } let certValue = response[0].spec?.rgw_frontend_ssl_certificate || ''; if (response[0].spec?.ssl_cert) { @@ -805,6 +826,9 @@ export class ServiceFormComponent extends CdForm implements OnInit { } } this.serviceForm.get('ssl_cert').setValue(certValue); + if (response[0].spec?.custom_sans) { + this.serviceForm.get('custom_sans').setValue(response[0].spec.custom_sans); + } } break; case 'ingress': @@ -830,7 +854,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { this.port = response[0].spec?.port; this.serviceForm.get('ssl').setValue(true); if (response[0].spec?.certificate_source !== 'cephadm-signed') { - this.serviceForm.get('certificateType').setValue('external'); + this.serviceForm.get('certificateType').setValue(CertificateType.external); } if (response[0].spec?.ssl_protocols) { let selectedValues: Array = []; @@ -1232,15 +1256,37 @@ export class ServiceFormComponent extends CdForm implements OnInit { } } - onCertificateTypeChange(type: string) { + onCertificateTypeChange(type: CertificateType) { this.serviceForm.get('certificateType').setValue(type); if (this.editing && this.currentCertificate?.has_certificate) { const originalSource = - this.currentSpecCertificateSource === 'cephadm-signed' ? 'internal' : 'external'; + this.currentSpecCertificateSource === 'cephadm-signed' + ? CertificateType.internal + : CertificateType.external; this.showCertSourceChangeWarning = type !== originalSource; } } + private getRgwModuleStatus(): void { + this.rgwMultisiteService.getRgwModuleStatus().subscribe((enabled: boolean) => { + this.rgwModuleEnabled = enabled; + const virtualHostControl = this.serviceForm.get('virtual_host_enabled'); + if (enabled) { + virtualHostControl.enable({ emitEvent: false }); + if (this.serviceForm.get('zonegroup_hostnames').value?.length) { + virtualHostControl.setValue(true, { emitEvent: false }); + } + } else { + virtualHostControl.setValue(false, { emitEvent: false }); + virtualHostControl.disable({ emitEvent: false }); + } + }); + } + + enableRgwModule(): void { + this.mgrModuleService.updateModuleState('rgw', false, null, '', $localize`Enabled RGW Module`); + } + prePopulateId() { const control: AbstractControl = this.serviceForm.get('service_id'); const backendService = this.serviceForm.getValue('backend_service'); @@ -1386,11 +1432,21 @@ export class ServiceFormComponent extends CdForm implements OnInit { serviceSpec['rgw_frontend_port'] = values['rgw_frontend_port']; } serviceSpec['ssl'] = values['ssl']; + if (values['virtual_host_enabled'] && values['zonegroup_hostnames']?.length > 0) { + serviceSpec['zonegroup_hostnames'] = values['zonegroup_hostnames']; + } if (values['ssl']) { this.applySslCertificateConfig(serviceSpec, values, { sslCertField: 'rgw_frontend_ssl_certificate', includeSslKey: false }); + if ( + values['certificateType'] === CertificateType.internal && + values['virtual_host_enabled'] && + values['zonegroup_hostnames']?.length > 0 + ) { + serviceSpec['wildcard_enabled'] = values['wildcard_enabled']; + } } break; case 'iscsi': @@ -1538,7 +1594,8 @@ export class ServiceFormComponent extends CdForm implements OnInit { get showExternalSslCert(): boolean { const serviceType = this.serviceForm.controls.service_type?.value; - const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external'; + const isExternalCert = + this.serviceForm.controls.certificateType?.value === CertificateType.external; const isSslEnabled = this.serviceForm.controls.ssl?.value; if (serviceType === 'mgmt-gateway') { @@ -1551,7 +1608,8 @@ export class ServiceFormComponent extends CdForm implements OnInit { get showExternalSslKey(): boolean { const serviceType = this.serviceForm.controls.service_type?.value; - const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external'; + const isExternalCert = + this.serviceForm.controls.certificateType?.value === CertificateType.external; const isSslEnabled = this.serviceForm.controls.ssl?.value; const sslKeyServices = ['iscsi', 'grafana', 'oauth2-proxy', 'nvmeof', 'nfs', 'mgmt-gateway']; @@ -1585,13 +1643,16 @@ export class ServiceFormComponent extends CdForm implements OnInit { } = options; serviceSpec['certificate_source'] = - values['certificateType'] === 'internal' ? 'cephadm-signed' : 'inline'; + values['certificateType'] === CertificateType.internal ? 'cephadm-signed' : 'inline'; - if (values['certificateType'] === 'internal' && values['custom_sans']?.length > 0) { + if ( + values['certificateType'] === CertificateType.internal && + values['custom_sans']?.length > 0 + ) { serviceSpec['custom_sans'] = values['custom_sans']; } - if (values['certificateType'] === 'external') { + if (values['certificateType'] === CertificateType.external) { serviceSpec[sslCertField] = values['ssl_cert']?.trim(); if (includeSslKey) { serviceSpec[sslKeyField] = values['ssl_key']?.trim(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts index af1e36c703d..eaca956d3ad 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts @@ -91,6 +91,9 @@ export interface CephServiceAdditionalSpec { ssl_protocols: string[]; ssl_ciphers: string[]; certificate_source: string; + custom_sans?: string[]; + zonegroup_hostnames?: string[]; + wildcard_enabled?: boolean; port: number; initial_admin_password: string; rgw_realm: string; @@ -123,6 +126,11 @@ export interface QatSepcs { [key: string]: string; } +export enum CertificateType { + internal = 'internal', + external = 'external' +} + export enum QatOptions { hw = 'hw', sw = 'sw', From 517c72f659ee194e089d3b3615d58c23c3aa3123 Mon Sep 17 00:00:00 2001 From: Adarsha Dinda Date: Thu, 11 Jun 2026 16:41:51 +0530 Subject: [PATCH 325/596] qa/smoke/basic: add log-ignorelist entries for expected smoke test warnings Ignore FS_DEGRADED in libcephfs_interface_tests and MON_DOWN in rbd_python_api_tests to avoid false failures from transient cluster health messages. Signed-off-by: Adarsha Dinda --- qa/suites/smoke/basic/tasks/test/libcephfs_interface_tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/qa/suites/smoke/basic/tasks/test/libcephfs_interface_tests.yaml b/qa/suites/smoke/basic/tasks/test/libcephfs_interface_tests.yaml index 3be975b6bf2..48d1d9e3aeb 100644 --- a/qa/suites/smoke/basic/tasks/test/libcephfs_interface_tests.yaml +++ b/qa/suites/smoke/basic/tasks/test/libcephfs_interface_tests.yaml @@ -11,6 +11,7 @@ tasks: - ceph: log-ignorelist: - \(POOL_APP_NOT_ENABLED\) + - \(FS_DEGRADED\) - ceph-fuse: - workunit: clients: From daec7e125a167bb8fd0fb9faaa04879f0bb215d2 Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Thu, 18 Jun 2026 07:19:43 +0200 Subject: [PATCH 326/596] ceph-volume: skip internal raid mirror LVs in inventory ceph-volume inventory started including all LVM mapper devices after c06bee965f1. On hosts with raid mirrored system volumes, that pulls in hidden legs like var_rmeta_0 which have no /dev/vg/lv node and makes cephadm's ceph-volume inventory call fail. Skip those internal LVs in get_devices() and avoid rewriting the device path to a missing lv_path in Device._parse(). Fixces: https://tracker.ceph.com/issues/77486 Signed-off-by: Guillaume Abrioux --- .../ceph_volume/tests/util/test_device.py | 22 ++++++ .../ceph_volume/tests/util/test_disk.py | 72 +++++++++++++++++++ src/ceph-volume/ceph_volume/util/device.py | 8 ++- src/ceph-volume/ceph_volume/util/disk.py | 47 +++++++++--- 4 files changed, 136 insertions(+), 13 deletions(-) diff --git a/src/ceph-volume/ceph_volume/tests/util/test_device.py b/src/ceph-volume/ceph_volume/tests/util/test_device.py index ea7d97e1634..77007749b82 100644 --- a/src/ceph-volume/ceph_volume/tests/util/test_device.py +++ b/src/ceph-volume/ceph_volume/tests/util/test_device.py @@ -313,11 +313,13 @@ class TestDevice(object): assert not disk.available @patch("ceph_volume.util.disk.has_bluestore_label", lambda x: False) + @patch('ceph_volume.util.device.disk.path_is_block_device', return_value=True) @patch('ceph_volume.util.device.os.path.realpath') @patch('ceph_volume.util.device.os.path.islink') def test_reject_lv_symlink_to_device(self, m_os_path_islink, m_os_path_realpath, + m_path_is_block_device, device_info, fake_call): m_os_path_islink.return_value = True @@ -327,6 +329,26 @@ class TestDevice(object): device_info(lv=lv,lsblk=lsblk) disk = device.Device("/dev/vg/lv") assert disk.path == '/dev/vg/lv' + m_path_is_block_device.assert_called_with('/dev/vg/lv') + + @patch("ceph_volume.util.disk.has_bluestore_label", lambda x: False) + @patch('ceph_volume.util.device.disk.path_is_block_device', return_value=False) + @patch('ceph_volume.util.device.os.path.realpath') + @patch('ceph_volume.util.device.os.path.islink') + def test_lv_keeps_mapper_path_when_lv_path_missing(self, + m_os_path_islink, + m_os_path_realpath, + m_path_is_block_device, + device_info, + fake_call): + m_os_path_islink.return_value = False + m_os_path_realpath.return_value = '/dev/mapper/vg-var_rmeta_0' + lv = {"lv_path": "/dev/vg/var_rmeta_0", "vg_name": "vg", "name": "var_rmeta_0", "tags": {}} + lsblk = {"TYPE": "lvm", "NAME": "vg-var_rmeta_0"} + device_info(lv=lv, lsblk=lsblk) + disk = device.Device("/dev/mapper/vg-var_rmeta_0") + assert disk.path == '/dev/mapper/vg-var_rmeta_0' + m_path_is_block_device.assert_called_with('/dev/vg/var_rmeta_0') @patch("ceph_volume.util.disk.has_bluestore_label", lambda x: False) def test_reject_smaller_than_5gb(self, fake_call, device_info): diff --git a/src/ceph-volume/ceph_volume/tests/util/test_disk.py b/src/ceph-volume/ceph_volume/tests/util/test_disk.py index b5e7450c417..d081e378ad5 100644 --- a/src/ceph-volume/ceph_volume/tests/util/test_disk.py +++ b/src/ceph-volume/ceph_volume/tests/util/test_disk.py @@ -428,8 +428,10 @@ class TestGetDevices(object): fake_filesystem.create_file('/sys/block/dm-0/size', contents='204800') fake_filesystem.create_file('/sys/block/dm-0/queue/rotational', contents='1') fake_filesystem.create_file('/sys/block/dm-0/queue/hw_sector_size', contents='512') + fake_filesystem.create_file(lv_path, st_mode=(stat.S_IFBLK | 0o600)) with patch("ceph_volume.util.disk.UdevData") as MockUdevData: mock_instance = MagicMock() + mock_instance.is_internal_lv = False mock_instance.preferred_block_path = lv_path mock_instance.environment = {} MockUdevData.return_value = mock_instance @@ -438,6 +440,46 @@ class TestGetDevices(object): assert result[lv_path]['type'] == 'lvm' assert result[lv_path]['human_readable_size'] == '100.00 MB' + def test_internal_raid_lv_is_excluded(self, patched_get_block_devs_sysfs, fake_filesystem): + mapper_path = '/dev/mapper/debian-var_rmeta_0' + dm_path = '/dev/dm-5' + patched_get_block_devs_sysfs.return_value = [ + [dm_path, mapper_path, 'lvm', dm_path] + ] + fake_filesystem.create_dir('/sys/block/dm-5/slaves') + fake_filesystem.create_dir('/sys/block/dm-5/queue') + fake_filesystem.create_file('/sys/block/dm-5/size', contents='8192') + fake_filesystem.create_file('/sys/block/dm-5/queue/rotational', contents='1') + fake_filesystem.create_file('/sys/block/dm-5/queue/hw_sector_size', contents='512') + with patch("ceph_volume.util.disk.UdevData") as MockUdevData: + mock_instance = MagicMock() + mock_instance.is_internal_lv = True + MockUdevData.return_value = mock_instance + result = disk.get_devices() + assert mapper_path not in result + assert not result + + def test_lvm_device_without_accessible_node_is_excluded( + self, patched_get_block_devs_sysfs, fake_filesystem + ): + mapper_path = '/dev/mapper/debian-var_rmeta_0' + dm_path = '/dev/dm-5' + patched_get_block_devs_sysfs.return_value = [ + [dm_path, mapper_path, 'lvm', dm_path] + ] + fake_filesystem.create_dir('/sys/block/dm-5/slaves') + fake_filesystem.create_dir('/sys/block/dm-5/queue') + fake_filesystem.create_file('/sys/block/dm-5/size', contents='8192') + fake_filesystem.create_file('/sys/block/dm-5/queue/rotational', contents='1') + fake_filesystem.create_file('/sys/block/dm-5/queue/hw_sector_size', contents='512') + with patch("ceph_volume.util.disk.UdevData") as MockUdevData: + mock_instance = MagicMock() + mock_instance.is_internal_lv = False + mock_instance.preferred_block_path = '/dev/debian/var_rmeta_0' + MockUdevData.return_value = mock_instance + result = disk.get_devices() + assert not result + def test_nvme_reads_vendor_model_rev_under_controller( self, patched_get_block_devs_sysfs, fake_filesystem ): @@ -1047,6 +1089,36 @@ V:1""" def test_dashed_path_with_lvm(self) -> None: assert disk.UdevData(self.fake_device).dashed_path == '/dev/mapper/fake_vg1-fake-lv1' + @patch('ceph_volume.util.disk.os.stat', _udev_data_patched_os_stat) + @patch('ceph_volume.util.disk.os.minor', Mock(return_value=1)) + @patch('ceph_volume.util.disk.os.major', Mock(return_value=998)) + def test_is_internal_lv_true_for_raid_metadata(self) -> None: + udev = disk.UdevData(self.fake_device) + udev.environment['DM_LV_NAME'] = 'var_rmeta_0' + assert udev.is_internal_lv + + @patch('ceph_volume.util.disk.os.stat', _udev_data_patched_os_stat) + @patch('ceph_volume.util.disk.os.minor', Mock(return_value=1)) + @patch('ceph_volume.util.disk.os.major', Mock(return_value=998)) + def test_is_internal_lv_true_for_raid_image(self) -> None: + udev = disk.UdevData(self.fake_device) + udev.environment['DM_LV_NAME'] = 'var_rimage_1' + assert udev.is_internal_lv + + @patch('ceph_volume.util.disk.os.stat', _udev_data_patched_os_stat) + @patch('ceph_volume.util.disk.os.minor', Mock(return_value=1)) + @patch('ceph_volume.util.disk.os.major', Mock(return_value=998)) + def test_is_internal_lv_true_for_layered_lv(self) -> None: + udev = disk.UdevData(self.fake_device) + udev.environment['DM_LV_LAYER'] = 'var' + assert udev.is_internal_lv + + @patch('ceph_volume.util.disk.os.stat', _udev_data_patched_os_stat) + @patch('ceph_volume.util.disk.os.minor', Mock(return_value=1)) + @patch('ceph_volume.util.disk.os.major', Mock(return_value=998)) + def test_is_internal_lv_false_for_public_lv(self) -> None: + assert not disk.UdevData(self.fake_device).is_internal_lv + @patch('ceph_volume.util.disk.os.stat', _udev_data_patched_os_stat) @patch('ceph_volume.util.disk.os.minor', Mock(return_value=1)) @patch('ceph_volume.util.disk.os.major', Mock(return_value=998)) diff --git a/src/ceph-volume/ceph_volume/util/device.py b/src/ceph-volume/ceph_volume/util/device.py index c5b35e48598..5082c6aee28 100644 --- a/src/ceph-volume/ceph_volume/util/device.py +++ b/src/ceph-volume/ceph_volume/util/device.py @@ -236,7 +236,8 @@ class Device(object): if lv: self.lv_api = lv self.lvs = [lv] - self.path = lv.lv_path + if disk.path_is_block_device(lv.lv_path): + self.path = lv.lv_path self.vg_name = lv.vg_name self.lv_name = lv.name self.ceph_device_lvm = lvm.is_ceph_device(lv) @@ -319,7 +320,10 @@ class Device(object): src/common/blkdev.cc """ - udev_data = disk.UdevData(self.path) + try: + udev_data = disk.UdevData(self.path) + except RuntimeError: + return '' env = udev_data.environment parts: list[str] = [] model = env.get('ID_MODEL', '') diff --git a/src/ceph-volume/ceph_volume/util/disk.py b/src/ceph-volume/ceph_volume/util/disk.py index 70ece5b49dc..1c4b44f53de 100644 --- a/src/ceph-volume/ceph_volume/util/disk.py +++ b/src/ceph-volume/ceph_volume/util/disk.py @@ -175,6 +175,14 @@ def _stat_is_device(stat_obj): return stat.S_ISBLK(stat_obj) +def path_is_block_device(path: str) -> bool: + """True if ``path`` exists and is a block device (follows symlinks).""" + try: + return _stat_is_device(os.stat(path).st_mode) + except OSError: + return False + + def _lsblk_parser(line): """ Parses lines in lsblk output. Requires output to be in pair mode (``-P`` flag). Lines @@ -921,7 +929,22 @@ def get_devices(_sys_block_path='/sys/block', device=''): for block in block_devs: metadata: Dict[str, Any] = {} if block[2] == 'lvm': - block[1] = UdevData(block[1]).preferred_block_path + try: + udev_data = UdevData(block[1]) + except RuntimeError as exc: + logger.debug( + 'get_devices(): skipping LVM device %s: %s', block[1], exc) + continue + if udev_data.is_internal_lv: + logger.debug( + 'get_devices(): skipping internal LVM LV %s', block[1]) + continue + block[1] = udev_data.preferred_block_path + if not path_is_block_device(block[1]): + logger.debug( + 'get_devices(): skipping LVM device without accessible node %s', + block[1]) + continue devname = os.path.basename(block[0]) diskname = block[1] if block[2] not in block_types: @@ -1546,6 +1569,16 @@ class UdevData: """ return self.environment.get('DM_UUID', '').startswith('LVM') + @property + def is_internal_lv(self) -> bool: + lv_name = self.environment.get('DM_LV_NAME', '') + if not lv_name: + return False + for marker in ('_rmeta_', '_rimage_', '_rtmeta_', '_rtimage_'): + if marker in lv_name: + return True + return bool(self.environment.get('DM_LV_LAYER', '')) + @property def slashed_path(self) -> str: """Get the LVM path structured with slashes. @@ -1575,14 +1608,6 @@ class UdevData: result = f'/dev/mapper/{name}' return result - @staticmethod - def _path_is_block_device(path: str) -> bool: - """True if ``path`` exists and is a block device (follows symlinks).""" - try: - return _stat_is_device(os.stat(path).st_mode) - except OSError: - return False - @property def preferred_block_path(self) -> str: """Return a device path that exists for typical open(2) / blkid usage. @@ -1598,9 +1623,9 @@ class UdevData: if not self.is_lvm: return self.path slashed: str = self.slashed_path - if self._path_is_block_device(slashed): + if path_is_block_device(slashed): return slashed dashed: str = self.dashed_path - if self._path_is_block_device(dashed): + if path_is_block_device(dashed): return dashed return self.path From 8ac962c698df959da1866d41b04d69c806bb72c0 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sun, 17 Mar 2024 18:42:44 +0800 Subject: [PATCH 327/596] script/run-make: enable ASan when performing tests, we should enable sanitizers for detecting potential issues. so, in this change, we enable ASsan, TSan and UBSan. script/run-make.sh is used by our CI job for testing PRs, so enabling these sanitizers helps us to identify issues as early as possible. because ASan cannot be used along with TSan, we prefer using ASan for capturing memory related issue in favor of detecting the multi-threading issues. also, because of https://bugs.llvm.org/show_bug.cgi?id=23272, we cannot enable multiple sanitizers. but we should enable UBSan as well, once we can use a higher version of Clang than Clang-14. with Clang-14, when enabling UBSan, we'd have following FTBFS ``` error: Cannot represent a difference across sections ``` when compiling `src/tools/neorados.cc` Signed-off-by: Kefu Chai --- src/script/run-make.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/src/script/run-make.sh b/src/script/run-make.sh index 30ca35ce274..e990a827de5 100755 --- a/src/script/run-make.sh +++ b/src/script/run-make.sh @@ -143,6 +143,7 @@ EOM local cxx_compiler="${discovered_cxx_compiler}" local c_compiler="${discovered_c_compiler}" local cmake_opts + cmake_opts+=" -DWITH_ASAN=ON" cmake_opts+=" -DCMAKE_CXX_COMPILER=$cxx_compiler -DCMAKE_C_COMPILER=$c_compiler" cmake_opts+=" -DENABLE_GIT_VERSION=OFF" cmake_opts+=" -DWITH_GTEST_PARALLEL=ON" From 9df2719e63eed89781c0ab73db9cdfa8b320da22 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 19 Jun 2026 19:20:09 +0800 Subject: [PATCH 328/596] rocksdb: update submodule to fix FTBFS due to missing 59afb3d6 bumped rocksdb submodule in hope to address the FTBFS failure when building rocksdb with GCC 16, but the tree still failed to build: ``` In file included from /ceph/src/rocksdb/include/rocksdb/trace_record_result.h:14, from /ceph/src/rocksdb/trace_replay/trace_record_result.cc:6: /ceph/src/rocksdb/include/rocksdb/trace_record.h:55:32: error: expected ')' before 'timestamp' 55 | explicit TraceRecord(uint64_t timestamp); | ~ ^~~~~~~~~~ | ) /ceph/src/rocksdb/include/rocksdb/trace_record.h:63:11: error: 'uint64_t' does not name a type 63 | virtual uint64_t GetTimestamp() const; | ^~~~~~~~ /ceph/src/rocksdb/include/rocksdb/trace_record.h:1:1: note: 'uint64_t' is defined in header ''; this is probably fixable by adding '#include ' ``` in this change, we cherry-pick upstream fix to address this build failure. Signed-off-by: Kefu Chai --- src/rocksdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rocksdb b/src/rocksdb index af6e913d159..c7a7c6340c5 160000 --- a/src/rocksdb +++ b/src/rocksdb @@ -1 +1 @@ -Subproject commit af6e913d15956f15ab888d1d0eb13120f462acba +Subproject commit c7a7c6340c522d2ecc21625e2b726554cb61eb72 From 2aabf864f3140c92cbdb4ef0606cc6f6dec59134 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 19 Jun 2026 03:06:06 +0800 Subject: [PATCH 329/596] mgr/tox: run pytest in parallel The py3 and coverage tox environments run the full mgr pytest suite serially, which makes run-tox-mgr the longest test in CI. Add pytest-xdist and pass `-n auto` to both so the suite is distributed across the available CPUs. pytest-xdist is constrained to <2 to stay compatible with the pinned pytest-cov. Running in parallel also surfaced a hard-coded port in cephadm's test_node_proxy, which now allocates an ephemeral port per process. Signed-off-by: Sun Yuechi --- src/pybind/mgr/cephadm/tests/test_node_proxy.py | 12 +++++++++++- src/pybind/mgr/requirements.txt | 1 + src/pybind/mgr/tox.ini | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pybind/mgr/cephadm/tests/test_node_proxy.py b/src/pybind/mgr/cephadm/tests/test_node_proxy.py index dd5f99f508c..75fe7d6fab9 100644 --- a/src/pybind/mgr/cephadm/tests/test_node_proxy.py +++ b/src/pybind/mgr/cephadm/tests/test_node_proxy.py @@ -1,5 +1,6 @@ import cherrypy import json +import socket from _pytest.monkeypatch import MonkeyPatch from urllib.error import URLError from cherrypy.test import helper @@ -9,7 +10,16 @@ from cephadm.inventory import AgentCache, NodeProxyCache, Inventory from cephadm.ssl_cert_utils import SSLCerts from . import node_proxy_data -PORT = 58585 + +def _free_port() -> int: + # Pick an ephemeral port so the test server does not clash with other + # pytest-xdist workers running this module in parallel. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +PORT = _free_port() fake_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQDIZSujNBlKaLJzmvntjukjMA0GCSqGSIb3DQEBDQUAMCExDTAL\nBgNVBAoMBENlcGgxEDAOBgNVBAMMB2NlcGhhZG0wHhcNMjIwNzEzMTE0NzA3WhcN\nMzIwNzEwMTE0NzA3WjAhMQ0wCwYDVQQKDARDZXBoMRAwDgYDVQQDDAdjZXBoYWRt\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyyMe4DMA+MeYK7BHZMHB\nq7zjliEOcNgxomjU8qbf5USF7Mqrf6+/87XWqj4pCyAW8x0WXEr6A56a+cmBVmt+\nqtWDzl020aoId6lL5EgLLn6/kMDCCJLq++Lg9cEofMSvcZh+lY2f+1p+C+00xent\nrLXvXGOilAZWaQfojT2BpRnNWWIFbpFwlcKrlg2G0cFjV5c1m6a0wpsQ9JHOieq0\nSvwCixajwq3CwAYuuiU1wjI4oJO4Io1+g8yB3nH2Mo/25SApCxMXuXh4kHLQr/T4\n4hqisvG4uJYgKMcSIrWj5o25mclByGi1UI/kZkCUES94i7Z/3ihx4Bad0AMs/9tw\nFwIDAQABMA0GCSqGSIb3DQEBDQUAA4IBAQAf+pwz7Gd7mDwU2LY0TQXsK6/8KGzh\nHuX+ErOb8h5cOAbvCnHjyJFWf6gCITG98k9nxU9NToG0WYuNm/max1y/54f0dtxZ\npUo6KSNl3w6iYCfGOeUIj8isi06xMmeTgMNzv8DYhDt+P2igN6LenqWTVztogkiV\nxQ5ZJFFLEw4sN0CXnrZX3t5ruakxLXLTLKeE0I91YJvjClSBGkVJq26wOKQNHMhx\npWxeydQ5EgPZY+Aviz5Dnxe8aB7oSSovpXByzxURSabOuCK21awW5WJCGNpmqhWK\nZzACBDEstccj57c4OGV0eayHJRsluVr2e9NHRINZA3qdB37e6gsI1xHo\n-----END CERTIFICATE-----\n""" diff --git a/src/pybind/mgr/requirements.txt b/src/pybind/mgr/requirements.txt index 044674f9be5..96712691c76 100644 --- a/src/pybind/mgr/requirements.txt +++ b/src/pybind/mgr/requirements.txt @@ -3,3 +3,4 @@ asyncssh==2.9 kubernetes urllib3==1.26.15 pytest==7.4.4 +pytest-xdist <2 diff --git a/src/pybind/mgr/tox.ini b/src/pybind/mgr/tox.ini index 647b9e45a93..1443659719b 100644 --- a/src/pybind/mgr/tox.ini +++ b/src/pybind/mgr/tox.ini @@ -61,7 +61,7 @@ deps = -rrequirements.txt -rrook/requirements.txt commands = - pytest --doctest-modules {posargs:} + pytest -n auto --doctest-modules {posargs:} [testenv:nooptional] setenv = @@ -87,7 +87,7 @@ deps = # COVERAGE_REPORT=html tox -e coverage -- # htmlcov/index.html commands = - pytest --cov={posargs:.} --cov-report={env:COVERAGE_REPORT:term} {posargs:.} + pytest -n auto --cov={posargs:.} --cov-report={env:COVERAGE_REPORT:term} {posargs:.} [testenv:{,py37-,py38-,py39-,py310-}mypy] From 0705c6d60b152bc5e705ddf3b35f5f7dc0a33259 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Fri, 19 Jun 2026 14:38:10 +0800 Subject: [PATCH 330/596] cmake: make GTEST_PARALLEL_COMMAND visible to all test directories GTEST_PARALLEL_COMMAND was set as an ordinary variable inside the `if(NOT TARGET gtest-parallel_ext)` guard, so it only existed in the first directory that include()s AddCephTest (src/common/options). Later includes skip the guarded block and leave it empty, so PARALLEL unittests under src/test silently ran serially. Promote it to CACHE INTERNAL so it is visible across all directories regardless of include order. Signed-off-by: Sun Yuechi --- cmake/modules/AddCephTest.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmake/modules/AddCephTest.cmake b/cmake/modules/AddCephTest.cmake index 83f442aec19..49562e569b0 100644 --- a/cmake/modules/AddCephTest.cmake +++ b/cmake/modules/AddCephTest.cmake @@ -73,8 +73,11 @@ if(WITH_GTEST_PARALLEL) BUILD_COMMAND "" INSTALL_COMMAND "") add_dependencies(tests gtest-parallel_ext) + # CACHE INTERNAL: the set() runs only in the first directory to create the + # target, so a plain variable would be invisible to PARALLEL tests elsewhere. set(GTEST_PARALLEL_COMMAND - ${Python3_EXECUTABLE} ${gtest_parallel_source_dir}/gtest-parallel) + ${Python3_EXECUTABLE} ${gtest_parallel_source_dir}/gtest-parallel + CACHE INTERNAL "command to run a gtest binary through gtest-parallel") endif() endif() From 0e0d2c6b2b9135496d0d288b54c68d7308989d6f Mon Sep 17 00:00:00 2001 From: Anthony D'Atri Date: Fri, 19 Jun 2026 10:47:15 -0400 Subject: [PATCH 331/596] doc/rados/bluestore: Improve fast-onode-scan.rst Signed-off-by: Anthony D'Atri --- doc/rados/bluestore/fast-onode-scan.rst | 114 +++++++++++++----------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/doc/rados/bluestore/fast-onode-scan.rst b/doc/rados/bluestore/fast-onode-scan.rst index f82f21aab9d..320491a0e36 100644 --- a/doc/rados/bluestore/fast-onode-scan.rst +++ b/doc/rados/bluestore/fast-onode-scan.rst @@ -4,55 +4,59 @@ .. index:: bluestore; rocksdb; allocator -Since Pacific release BlueStore has the option to select 5-10% latency reduction -at a cost of significantly longer ``OSD`` recovery after crash. +Beginning with the Pacific release, BlueStore has the option to configure a 5-10% latency reduction +at a cost of significantly longer ``OSD`` recovery after a crash. .. confval:: bluestore_allocation_from_file -During crash recovery BlueStore must reconstruct allocator state by scanning all stored objects. -On systems with millions of objects this process can take several minutes. -This page presents new multithreaded onode recovery that significantly reduces recovery time. +During crash recovery BlueStore must reconstruct allocator state by scanning all stored RADOS objects. +For OSDs storing millions of RADOS objects this process may take several minutes to several hours to complete. +This page describes multithreaded onode recovery that significantly reduces recovery time. Allocator ========= -In BlueStore ``Allocator`` is responsible for tracking unused allocation units on the disk. -Up to 3 allocators can be in use concurrently, one for Main, one for DB, and one for WAL. -Only Main device can contain RADOS object, and only Main's allocator is relevant here. -When BlueStore is running, allocator state is fully loaded to memory. That way -allocator can quickly respond when there is a need to allocate disk space for object, -or when object no longer exists on disk. +In BlueStore, the configured ``Allocator`` is responsible for tracking unused allocation units on the underlying device. +As many as three allocators may be in use concurrently: one for the main block device, one for the DB, and one for the WAL. +Only the main device stores RADOS objects, and thus is the only allocator that is relevant here. +When a BlueStore OSD is running, allocator state is fully loaded in memory. This ensures that the +allocator can quickly respond when there is a need to allocate space for RADOS objects, +or when RADOS objects no longer exist and their underlying space should be reclaimed. -RocksDB persisted allocator +RocksDB-persisted allocator --------------------------- -Original design envisioned that allocator state updates are stored in RocksDB's ``b`` column. -When RADOS object change allocates or releases allocation unit the relevant state update information -is kept together with object-modifying RocksDB atomic transaction. The information transfer is unidirectional, -BlueStore is updating allocator state in RocksDB, but never retrieving it. -The exception is OSD bootup when BlueStore retrieves allocator state from RocksDB ``b`` column. +The original design envisioned that allocator state updates are stored in RocksDB's ``b`` column. +When RADOS objects are written or release allocation units, the relevant state information +is updated with an object-modifying RocksDB atomic transaction. The information transfer is unidirectional: +BlueStore updates allocator state in RocksDB, but never retrieves it. +The exception is at OSD boot when BlueStore retrieves allocator state from the RocksDB ``b`` column. -File persisted allocator +File-persisted allocator ------------------------ -It was tested that keeping RocksDB busy updating ``b`` column with allocation data -has observable cost on write latency and cpu burden on compaction. -To get that extra performance back the new default mode is to update in-memory allocator state only. -Instead, on shutdown state is saved to BlueFS file named ``ALLOCATOR_NCB_DIR/ALLOCATOR_NCB_FILE``. +Tests showed that keeping RocksDB busy by updating ``b`` column with allocation data +has observable cost in terms of write latency and CPU burden when compacting. +To avoid this performance impact the new default mode is to update allocator state only in-memory. +Only on shutdown is state saved to a BlueFS file named ``ALLOCATOR_NCB_DIR/ALLOCATOR_NCB_FILE``. Crash recovery --------------- +============== -If BlueStore was not shutdown orderly there is no file to read allocator state from. -Since it is the objects that define what is in use, the recovery procedure iterates over all objects and extracts used allocations. +If BlueStore was not shut down gracefully there will be no updated file from which to read allocator state. +Since RADOS objects are then the only only source of truth regarding underlying device usage, +the recovery procedure must iterate over all RADOS objects in order to determine used vs +available device blocks. -Multi-thread recovery -===================== +Multi-threaded recovery +======================= -Iterating over all onodes in the RocksDB can take several minutes. -To help with that a multi-threaded recovery procedure is created. -It is significantly different procedure than the original one. -There is just one configurable that selects and controls recovery. +Iterating over all onodes in the RocksDB can take several minutes on +a relatively small SSD; for large and slow devices it may take several *hours*. +To mitigate this significant impact on OSD boot time, a multi-threaded recovery procedure +has been added for the Vampire release and may be backported to Umbrella and Tentacle. +This is a significantly different procedure than the original. +One config option selects and controls this new recovery strategy. .. confval:: bluestore_allocation_recovery_threads @@ -60,32 +64,34 @@ There is just one configurable that selects and controls recovery. Performance ----------- -Example recovery timings. -OSD with NVME SSD, hosting 7.5M RBD objects, 4.6TiB used. +Below are example elapsed recovery timings for an +OSD on an NVMe SSD hosting 7.5M RBD objects, with 4.6TiB used: -+------------------------+------------+------------+ -| recovery mechanism | threads | time(s) | -+========================+============+============+ -| original | 1 | 76.0 | -+------------------------+------------+------------+ -| multithread | 1 | 55.8 | -+------------------------+------------+------------+ -| multithread | 4 | 18.1 | -+------------------------+------------+------------+ -| multithread | 8 | 10.8 | -+------------------------+------------+------------+ -| multithread | 12 | 8.0 | -+------------------------+------------+------------+ -| multithread | 16 | 6.8 | -+------------------------+------------+------------+ -| multithread | 32 | 5.3 | -+------------------------+------------+------------+ ++---------------------+------------+-----------------+ +| Recovery Mechanism | Threads | Time in seconds | ++=====================+============+=================+ +| original | 1 | 76.0 | ++---------------------+------------+-----------------+ +| multi-threaded | 1 | 55.8 | ++---------------------+------------+-----------------+ +| multi-threaded | 4 | 18.1 | ++---------------------+------------+-----------------+ +| multi-threaded | 8 | 10.8 | ++---------------------+------------+-----------------+ +| multi-threaded | 12 | 8.0 | ++---------------------+------------+-----------------+ +| multi-threaded | 16 | 6.8 | ++---------------------+------------+-----------------+ +| multit-hreaded | 32 | 5.3 | ++---------------------+------------+-----------------+ Testing ------- -If long recovery time has been a deciding factor for staying with allocations in RocksDB, -there is a procedure for measuring directly recovery time. +Concern for long recovery time has led some Ceph operators to configure +legacy synchronous persistance of allocator state in RocksDB. +There is now a procedure for predicting the legacy recovery time for a given OSD, +allowing an informed decision: .. prompt:: bash # @@ -163,6 +169,6 @@ there is a procedure for measuring directly recovery time. Allocators the same. recovery-compare success -One can see above discrepancy between legacy recovery times: 76.0s vs 62.2s. -This is an effect of RocksDB having the data already cached by new recovery when legacy recovery is the second one. +One can see in the above example the improvement in legacy recovery times: 76.0s vs 62.2s. +The second run shows the effect of RocksDB having the data already cached by the new recovery strategy. From ae6bc8220b7b7a229d32064ea5dc2f3d88badbae Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 7 Jun 2026 02:05:34 +0800 Subject: [PATCH 332/596] test: optionally run test venvs with system site-packages Add a CEPH_PYTHON_SYSTEM_SITE switch (off by default). When set: - setup-virtualenv.sh builds its venv with --system-site-packages; - run_tox.sh exports VIRTUALENV_SYSTEM_SITE_PACKAGES=true for tox's venvs. This lets distro packages satisfy test dependencies instead of pip building them from sdist, which helps where prebuilt wheels are missing (e.g. scipy and numpy on riscv64) by avoiding a slow rebuild when the RPMs are installed. Signed-off-by: Sun Yuechi --- src/script/run_tox.sh | 4 ++++ src/tools/setup-virtualenv.sh | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/script/run_tox.sh b/src/script/run_tox.sh index f2f71a60f29..61fdea28ccf 100755 --- a/src/script/run_tox.sh +++ b/src/script/run_tox.sh @@ -125,6 +125,10 @@ function main() { export CEPH_BUILD_DIR=$build_dir # use the wheelhouse prepared by install-deps.sh export PIP_FIND_LINKS="$tox_path/wheelhouse" + # CEPH_PYTHON_SYSTEM_SITE (default off): let tox's venvs see system site-packages + if ${CEPH_PYTHON_SYSTEM_SITE:-false}; then + export VIRTUALENV_SYSTEM_SITE_PACKAGES=true + fi tox_cmd=(tox -c $tox_path/tox.ini) if [ "$tox_envs" != "__tox_defaults__" ]; then tox_cmd+=("-e" "$tox_envs") diff --git a/src/tools/setup-virtualenv.sh b/src/tools/setup-virtualenv.sh index 152e4859715..5dba693c26e 100755 --- a/src/tools/setup-virtualenv.sh +++ b/src/tools/setup-virtualenv.sh @@ -62,7 +62,12 @@ if [ -z "$DIR" ] ; then fi rm -fr $DIR mkdir -p $DIR -$PYTHON -m venv $DIR +# CEPH_PYTHON_SYSTEM_SITE (default off): build venv against system site-packages +VENV_OPTS= +if ${CEPH_PYTHON_SYSTEM_SITE:-false}; then + VENV_OPTS="--system-site-packages" +fi +$PYTHON -m venv $VENV_OPTS $DIR . $DIR/bin/activate if pip --help | grep -q disable-pip-version-check; then From ba5da131ec745f5eab799e9de6b374b1e101d851 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 7 Jun 2026 22:19:08 +0800 Subject: [PATCH 333/596] mypy: skip follow_imports for prettytable With test venvs on system site-packages, mypy picks up the system prettytable (3.4.0+, typed). It flags mgr's add_row(tuple) against the list[Any] signature (src/mypy.ini) and qa's float_format = str against the dict[str, str] property (qa/mypy.ini). Skip follow_imports in both. Signed-off-by: Sun Yuechi --- qa/mypy.ini | 6 +++++- src/mypy.ini | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/qa/mypy.ini b/qa/mypy.ini index 1215375ed9b..3d88b720b94 100644 --- a/qa/mypy.ini +++ b/qa/mypy.ini @@ -1,2 +1,6 @@ [mypy] -ignore_missing_imports = True \ No newline at end of file +ignore_missing_imports = True + +[mypy-prettytable] +ignore_missing_imports = True +follow_imports = skip diff --git a/src/mypy.ini b/src/mypy.ini index 9fe2f82a535..579644374c5 100755 --- a/src/mypy.ini +++ b/src/mypy.ini @@ -36,6 +36,7 @@ ignore_missing_imports = True [mypy-prettytable] ignore_missing_imports = True +follow_imports = skip [mypy-jsonpatch] ignore_missing_imports = True From 3652faa3fbc752407d57f74b8c258cbf0d0cd56e Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sat, 20 Jun 2026 13:51:39 +0800 Subject: [PATCH 334/596] test/cmake: drop dead env_vars_for_tox_tests set_property Both `tox_tests` and `env_vars_for_tox_tests` have been undefined since f0079a1030b, so this expands to `set_property(TEST PROPERTY ENVIRONMENT)` -- a no-op. The actual per-test environment for tox tests is set in add_tox_test() (cmake/modules/AddCephTest.cmake). Remove the leftover. Signed-off-by: Sun Yuechi --- src/test/CMakeLists.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index ecba4a139bc..caba0b1f236 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -684,10 +684,6 @@ add_ceph_test(run-cli-tests ${CMAKE_CURRENT_SOURCE_DIR}/run-cli-tests) add_ceph_test(smoke.sh ${CMAKE_CURRENT_SOURCE_DIR}/smoke.sh) -set_property( - TEST ${tox_tests} - PROPERTY ENVIRONMENT ${env_vars_for_tox_tests}) - # unittest_admin_socket add_executable(unittest_admin_socket admin_socket.cc From 5d82294f3f5b6204f2fe601f1e2349d806854d9e Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sat, 20 Jun 2026 10:53:51 +0800 Subject: [PATCH 335/596] cmake/boost: load context Jamfile before passing context-impl to b2 With WITH_ASAN, b2 runs as `b2 context-impl=ucontext headers stage` for the build step and `b2 context-impl=ucontext install` for the install step. The `context-impl` feature is declared in libs/context/build/Jamfile.v2, which neither the headers/stage nor the install targets load, so b2 aborts with: error: unknown feature "" Name the context project as a target in both commands so its Jamfile (and the feature) loads before the build request is expanded. This works around https://github.com/boostorg/context/issues/297, fixed upstream in https://github.com/boostorg/context/commit/12ac945158ae3c2373ec0c888899373218aa209f and first released in Boost 1.88; drop it once the bundled Boost is bumped to 1.88 or newer. Signed-off-by: Sun Yuechi --- cmake/modules/BuildBoost.cmake | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmake/modules/BuildBoost.cmake b/cmake/modules/BuildBoost.cmake index 4b80296f9df..d47e1eeb471 100644 --- a/cmake/modules/BuildBoost.cmake +++ b/cmake/modules/BuildBoost.cmake @@ -144,15 +144,23 @@ function(do_build_boost root_dir version) if(WITH_BOOST_VALGRIND) list(APPEND b2 valgrind=on) endif() + set(b2_targets headers stage) + set(b2_install_targets install) if(WITH_ASAN) list(APPEND b2 context-impl=ucontext) + # `context-impl` is declared in libs/context/build/Jamfile.v2; the headers/stage + # and install targets never load it, so b2 aborts with `unknown feature + # ""`. Name the context project as a target so its Jamfile loads + # the feature first. + list(PREPEND b2_targets libs/context/build) + list(PREPEND b2_install_targets libs/context/build) endif() set(build_command - ${b2} headers stage + ${b2} ${b2_targets} #"--buildid=ceph" # changes lib names--can omit for static ${boost_features}) set(install_command - ${b2} install) + ${b2} ${b2_install_targets}) if(EXISTS "${PROJECT_SOURCE_DIR}/src/boost/bootstrap.sh") check_boost_version("${PROJECT_SOURCE_DIR}/src/boost" ${version}) set(source_dir From a764ecf720c536cec35095872e1db41b363104a3 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sat, 20 Jun 2026 23:21:01 +0800 Subject: [PATCH 336/596] monitoring/ceph-mixin: make jsonnet-bundler clone idempotent A run that aborts before the cleanup fixture runs (timeout, OOM, interrupt) leaves the clone dir behind, so the next build fails with "destination path already exists". Remove it first. Signed-off-by: Sun Yuechi --- monitoring/ceph-mixin/jsonnet-bundler-build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monitoring/ceph-mixin/jsonnet-bundler-build.sh b/monitoring/ceph-mixin/jsonnet-bundler-build.sh index 463658f186b..bb5b323500c 100755 --- a/monitoring/ceph-mixin/jsonnet-bundler-build.sh +++ b/monitoring/ceph-mixin/jsonnet-bundler-build.sh @@ -3,6 +3,8 @@ JSONNET_VERSION="v0.6.0" OUTPUT_DIR=${1:-$(pwd)} +# clean up leftovers from an aborted run so the clone stays idempotent +rm -rf jsonnet-bundler git clone -b ${JSONNET_VERSION} --depth 1 https://github.com/jsonnet-bundler/jsonnet-bundler make -C jsonnet-bundler build mv jsonnet-bundler/_output/jb ${OUTPUT_DIR} From b11c0eaedc3c9108bab9c85b72d4fcc074c8e1d5 Mon Sep 17 00:00:00 2001 From: Dax Kelson Date: Sat, 20 Jun 2026 13:18:19 -0600 Subject: [PATCH 337/596] msg/async/rdma: don't abort on an invalid per-connection eventfd The async RDMA messenger reports each connection's per-connection eventfd (notify_fd) as its socket fd(). notify_fd can legitimately be -1 -- under fd-table pressure eventfd() returns -1, and the base RDMAConnectedSocketImpl constructor skips queue pair *and* eventfd creation entirely when ms_async_rdma_cm=true (the cm path is only implemented by the iwarp socket classes). Either way an fd() == -1 socket reaches the success path of Processor::accept and trips ceph_assert(socket.fd() >= 0) in AsyncConnection::accept, or is fed to EventCenter::create_file_event() and indexes file_events[-1] out of bounds. Both abort the OSD under connection load. Make the messenger surface the failure instead of handing back a broken socket: - Initialize RDMAConnectedSocketImpl::qp to nullptr. It was uninitialized and read via get_qp() when ms_async_rdma_cm=true skips its assignment. - Create the notify eventfd before the queue pair in the base constructor, and adopt the cm id/channel before the eventfd in the iwarp constructor, so a failure leaks nothing and the two failure causes stay distinguishable. Check the rdma_create_event_channel()/rdma_create_id() return values on the touched paths, and destroy the per-request cm id on the accept error paths (acking the event does not free it). - At each accept/connect construction site, reject a socket with fd() < 0 (-EMFILE) or a null queue pair (-EIO) instead of returning it. Free the half-built socket on its owning worker thread via center.submit_to() so ~RDMAConnectedSocketImpl's center.in_thread() assertion holds and no RDMA/fd state leaks (this also fixes the pre-existing leak on the rdma_accept() failure path). - Require ms_async_rdma_cm and ms_async_rdma_type=iwarp to be enabled together in RDMAWorker::listen()/connect() (-EOPNOTSUPP with a clear message). The two are coupled -- only the iwarp classes implement rdma_cm and they always use it -- so any mismatch leaves a socket half-built and would otherwise crash-loop the daemon. - Guard EventCenter::create_file_event() against a negative fd as defense in depth. Fixes: https://tracker.ceph.com/issues/77547 Signed-off-by: Dax Kelson --- src/msg/async/Event.cc | 1 + src/msg/async/rdma/RDMAConnectedSocketImpl.cc | 14 +++++- .../rdma/RDMAIWARPConnectedSocketImpl.cc | 38 +++++++++++++--- .../async/rdma/RDMAIWARPServerSocketImpl.cc | 31 ++++++++++++- src/msg/async/rdma/RDMAServerSocketImpl.cc | 20 ++++++--- src/msg/async/rdma/RDMAStack.cc | 43 ++++++++++++++++++- src/msg/async/rdma/RDMAStack.h | 2 +- 7 files changed, 131 insertions(+), 18 deletions(-) diff --git a/src/msg/async/Event.cc b/src/msg/async/Event.cc index 7399494a93f..567bb9e0ea0 100644 --- a/src/msg/async/Event.cc +++ b/src/msg/async/Event.cc @@ -253,6 +253,7 @@ void EventCenter::set_owner() int EventCenter::create_file_event(int fd, int mask, EventCallbackRef ctxt) { ceph_assert(in_thread()); + ceph_assert(fd >= 0); int r = 0; if (fd >= nevent) { int new_size = nevent << 2; diff --git a/src/msg/async/rdma/RDMAConnectedSocketImpl.cc b/src/msg/async/rdma/RDMAConnectedSocketImpl.cc index d5ebf8ae2cc..6bbd672968e 100644 --- a/src/msg/async/rdma/RDMAConnectedSocketImpl.cc +++ b/src/msg/async/rdma/RDMAConnectedSocketImpl.cc @@ -58,13 +58,22 @@ RDMAConnectedSocketImpl::RDMAConnectedSocketImpl(CephContext *cct, std::shared_p active(false), pending(false) { if (!cct->_conf->ms_async_rdma_cm) { + // Create the per-connection notify eventfd before the queue pair. Under fd + // pressure eventfd() can fail (-1); doing it first means a failure leaves no + // queue pair to leak. The two failure states are kept distinct on purpose so + // the accept/connect paths can return an honest errno: eventfd failure -> + // fd() < 0 && qp == nullptr; queue pair failure -> fd() >= 0 && qp == nullptr. + notify_fd = eventfd(0, EFD_CLOEXEC|EFD_NONBLOCK); + if (notify_fd < 0) { + lderr(cct) << __func__ << " failed to create eventfd: " << cpp_strerror(errno) << dendl; + return; + } qp = ib->create_queue_pair(cct, dispatcher->get_tx_cq(), dispatcher->get_rx_cq(), IBV_QPT_RC, NULL); if (!qp) { lderr(cct) << __func__ << " queue pair create failed" << dendl; return; } local_qpn = qp->get_local_qp_number(); - notify_fd = eventfd(0, EFD_CLOEXEC|EFD_NONBLOCK); dispatcher->register_qp(qp, this); dispatcher->perf_logger->inc(l_msgr_rdma_created_queue_pair); dispatcher->perf_logger->inc(l_msgr_rdma_active_queue_pair); @@ -76,7 +85,8 @@ RDMAConnectedSocketImpl::~RDMAConnectedSocketImpl() ldout(cct, 20) << __func__ << " destruct." << dendl; cleanup(); worker->remove_pending_conn(this); - dispatcher->schedule_qp_destroy(local_qpn); + if (qp) // null on construction-failure paths; local_qpn 0 is unregistered + dispatcher->schedule_qp_destroy(local_qpn); for (unsigned i=0; i < wc.size(); ++i) { dispatcher->post_chunk_to_pool(reinterpret_cast(wc[i].wr_id)); diff --git a/src/msg/async/rdma/RDMAIWARPConnectedSocketImpl.cc b/src/msg/async/rdma/RDMAIWARPConnectedSocketImpl.cc index 606dbd2817c..1844f695c88 100644 --- a/src/msg/async/rdma/RDMAIWARPConnectedSocketImpl.cc +++ b/src/msg/async/rdma/RDMAIWARPConnectedSocketImpl.cc @@ -13,13 +13,43 @@ RDMAIWARPConnectedSocketImpl::RDMAIWARPConnectedSocketImpl(CephContext *cct, std : RDMAConnectedSocketImpl(cct, ib, rdma_dispatcher, w), cm_con_handler(new C_handle_cm_connection(this)) { status = IDLE; - notify_fd = eventfd(0, EFD_CLOEXEC|EFD_NONBLOCK); + // Take ownership of the CM id/channel *before* creating the notify eventfd, so + // that if eventfd() fails (e.g. under fd exhaustion) the destructor still frees + // them (it only does so for status >= RDMA_ID_CREATED). Every failure path + // calls close_notify() so a later delete cannot block on close_condition. if (info) { is_server = true; cm_id = info->cm_id; cm_channel = info->cm_channel; status = RDMA_ID_CREATED; peer_qpn = info->qp_num; + } else { + is_server = false; + cm_channel = rdma_create_event_channel(); + if (!cm_channel) { + lderr(cct) << __func__ << " failed to create cm event channel: " << cpp_strerror(errno) << dendl; + close_notify(); + return; + } + if (rdma_create_id(cm_channel, &cm_id, NULL, RDMA_PS_TCP)) { + lderr(cct) << __func__ << " failed to create cm id: " << cpp_strerror(errno) << dendl; + rdma_destroy_event_channel(cm_channel); + cm_channel = nullptr; + close_notify(); + return; + } + status = RDMA_ID_CREATED; + ldout(cct, 20) << __func__ << " successfully created cm id: " << cm_id << dendl; + } + + notify_fd = eventfd(0, EFD_CLOEXEC|EFD_NONBLOCK); + if (notify_fd < 0) { + lderr(cct) << __func__ << " failed to create eventfd: " << cpp_strerror(errno) << dendl; + close_notify(); + return; + } + + if (info) { if (alloc_resource()) { close_notify(); return; @@ -31,12 +61,6 @@ RDMAIWARPConnectedSocketImpl::RDMAIWARPConnectedSocketImpl(CephContext *cct, std status = RESOURCE_ALLOCATED; qp->get_local_cm_meta().peer_qpn = peer_qpn; qp->get_peer_cm_meta().local_qpn = peer_qpn; - } else { - is_server = false; - cm_channel = rdma_create_event_channel(); - rdma_create_id(cm_channel, &cm_id, NULL, RDMA_PS_TCP); - status = RDMA_ID_CREATED; - ldout(cct, 20) << __func__ << " successfully created cm id: " << cm_id << dendl; } } diff --git a/src/msg/async/rdma/RDMAIWARPServerSocketImpl.cc b/src/msg/async/rdma/RDMAIWARPServerSocketImpl.cc index 0500b4420f9..f5c3479b1de 100644 --- a/src/msg/async/rdma/RDMAIWARPServerSocketImpl.cc +++ b/src/msg/async/rdma/RDMAIWARPServerSocketImpl.cc @@ -71,13 +71,25 @@ int RDMAIWARPServerSocketImpl::accept(ConnectedSocket *sock, const SocketOptions rdma_get_cm_event(cm_channel, &cm_event); ldout(cct, 20) << __func__ << " event name: " << rdma_event_str(cm_event->event) << dendl; + // rdma_get_cm_event() handed us a new cm id for the incoming connection; on any + // error path before it is adopted into a socket (whose destructor would free it) + // we must rdma_destroy_id() it ourselves -- acking the event does not. The event + // must be acked *before* destroying the id: rdma_destroy_id() blocks until the + // connect-request event for that id has been acknowledged. struct rdma_cm_id *event_cm_id = cm_event->id; struct rdma_event_channel *event_channel = rdma_create_event_channel(); + if (!event_channel) { + lderr(cct) << __func__ << " failed to create event channel: " << cpp_strerror(errno) << dendl; + rdma_ack_cm_event(cm_event); + rdma_destroy_id(event_cm_id); + return -EMFILE; + } if (net.set_nonblock(event_channel->fd) < 0) { lderr(cct) << __func__ << " failed to switch event channel to non-block, close event channel " << dendl; rdma_destroy_event_channel(event_channel); rdma_ack_cm_event(cm_event); + rdma_destroy_id(event_cm_id); return -errno; } @@ -86,15 +98,32 @@ int RDMAIWARPServerSocketImpl::accept(ConnectedSocket *sock, const SocketOptions struct rdma_conn_param *remote_conn_param = &cm_event->param.conn; struct rdma_conn_param local_conn_param; + RDMAWorker *rw = dynamic_cast(w); RDMACMInfo info(event_cm_id, event_channel, remote_conn_param->qp_num); RDMAIWARPConnectedSocketImpl* server = - new RDMAIWARPConnectedSocketImpl(cct, ib, dispatcher, dynamic_cast(w), &info); + new RDMAIWARPConnectedSocketImpl(cct, ib, dispatcher, rw, &info); + + // Construction can fail under fd pressure (eventfd) or on queue pair creation; + // such a socket must not reach the messenger (its fd() is -1). Free it on its + // owning worker thread (close() marks it closed so ~IWARP won't block and frees + // the adopted cm id/channel) and return an honest errno (fd-first). + if (server->fd() < 0 || !server->get_qp()) { + int err = server->fd() < 0 ? -EMFILE : -EIO; + lderr(cct) << __func__ << " failed to create connected socket: " + << cpp_strerror(err) << dendl; + rdma_ack_cm_event(cm_event); + rw->center.submit_to(rw->center.get_id(), [server]() { server->close(); delete server; }, false); + return err; + } // FIPS zeroization audit 20191115: this memset is not security related. memset(&local_conn_param, 0, sizeof(local_conn_param)); local_conn_param.qp_num = server->get_local_qpn(); if (rdma_accept(event_cm_id, &local_conn_param)) { + lderr(cct) << __func__ << " rdma_accept failed: " << cpp_strerror(errno) << dendl; + rdma_ack_cm_event(cm_event); + rw->center.submit_to(rw->center.get_id(), [server]() { server->close(); delete server; }, false); return -EAGAIN; } server->activate(); diff --git a/src/msg/async/rdma/RDMAServerSocketImpl.cc b/src/msg/async/rdma/RDMAServerSocketImpl.cc index 69ad2d2dc94..4435c95915a 100644 --- a/src/msg/async/rdma/RDMAServerSocketImpl.cc +++ b/src/msg/async/rdma/RDMAServerSocketImpl.cc @@ -111,15 +111,23 @@ int RDMAServerSocketImpl::accept(ConnectedSocket *sock, const SocketOptions &opt out->set_sockaddr((sockaddr*)&ss); net.set_priority(sd, opt.priority, out->get_family()); + RDMAWorker *rw = dynamic_cast(w); RDMAConnectedSocketImpl* server; //Worker* w = dispatcher->get_stack()->get_worker(); - server = new RDMAConnectedSocketImpl(cct, ib, dispatcher, dynamic_cast(w)); - if (!server->get_qp()) { - lderr(cct) << __func__ << " server->qp is null" << dendl; - // cann't use delete server here, destructor will fail. - server->cleanup(); + server = new RDMAConnectedSocketImpl(cct, ib, dispatcher, rw); + // Construction can fail under fd pressure (eventfd) or on queue pair creation. + // Such a socket has fd() < 0 and/or a null qp; handing it back on the success + // path would trip ceph_assert(socket.fd() >= 0) in AsyncConnection::accept. + // Return an honest errno (fd-first, so eventfd exhaustion maps to -EMFILE rather + // than -EIO) and free the half-built socket on its owning worker thread, where + // ~RDMAConnectedSocketImpl's center.in_thread() assertion holds. + if (server->fd() < 0 || !server->get_qp()) { + int err = server->fd() < 0 ? -EMFILE : -EIO; + lderr(cct) << __func__ << " failed to create connected socket: " + << cpp_strerror(err) << dendl; + rw->center.submit_to(rw->center.get_id(), [server]() { delete server; }, false); ::close(sd); - return -1; + return err; } server->set_accept_fd(sd); ldout(cct, 20) << __func__ << " accepted a new QP, tcp_fd: " << sd << dendl; diff --git a/src/msg/async/rdma/RDMAStack.cc b/src/msg/async/rdma/RDMAStack.cc index 34dda383a3f..e8a86631628 100644 --- a/src/msg/async/rdma/RDMAStack.cc +++ b/src/msg/async/rdma/RDMAStack.cc @@ -692,6 +692,17 @@ void RDMAWorker::initialize() int RDMAWorker::listen(entity_addr_t &sa, unsigned addr_slot, const SocketOptions &opt,ServerSocket *sock) { + // The rdma_cm path and the iwarp socket classes are coupled: only the iwarp + // classes implement rdma_cm, and they always use it. Any other pairing leaves a + // socket half-built (the base ctor skips queue pair / eventfd creation when + // ms_async_rdma_cm=true, and a non-cm iwarp socket has no usable cm channel), so + // require the two to be enabled together and fail fast with a clear message. + if (cct->_conf->ms_async_rdma_cm != (cct->_conf->ms_async_rdma_type == "iwarp")) { + lderr(cct) << __func__ << " ms_async_rdma_cm and ms_async_rdma_type=iwarp must be " + "enabled together" << dendl; + return -EOPNOTSUPP; + } + ib->init(); dispatcher->polling_start(); @@ -713,15 +724,45 @@ int RDMAWorker::listen(entity_addr_t &sa, unsigned addr_slot, int RDMAWorker::connect(const entity_addr_t &addr, const SocketOptions &opts, ConnectedSocket *socket) { + const bool iwarp = cct->_conf->ms_async_rdma_type == "iwarp"; + // The rdma_cm (connection manager) path and the iwarp socket classes are coupled: + // only the iwarp classes implement rdma_cm, and they always use it. Any other + // pairing leaves a socket half-built -- with ms_async_rdma_cm=true and a non-iwarp + // type the base ctor skips queue pair / eventfd creation (fd() == -1 on every + // connection); with an iwarp type and ms_async_rdma_cm=false the cm channel is + // never set up. Require the two together and fail fast. + if (cct->_conf->ms_async_rdma_cm != iwarp) { + lderr(cct) << __func__ << " ms_async_rdma_cm and ms_async_rdma_type=iwarp must be " + "enabled together" << dendl; + return -EOPNOTSUPP; + } + ib->init(); dispatcher->polling_start(); RDMAConnectedSocketImpl* p; - if (cct->_conf->ms_async_rdma_type == "iwarp") { + if (iwarp) { p = new RDMAIWARPConnectedSocketImpl(cct, ib, dispatcher, this); } else { p = new RDMAConnectedSocketImpl(cct, ib, dispatcher, this); } + + // Reject a socket whose construction failed before wiring it up, otherwise it + // reaches the messenger with fd() == -1. fd-first so eventfd exhaustion maps to + // -EMFILE; the non-iwarp qp check then catches a queue pair failure that left a + // valid eventfd (an iwarp client legitimately has a null qp until its route is + // resolved, so it is excluded here). + if (p->fd() < 0) { + lderr(cct) << __func__ << " failed to create eventfd (fd limit reached?)" << dendl; + delete p; + return -EMFILE; + } + if (!iwarp && !p->get_qp()) { + lderr(cct) << __func__ << " failed to create queue pair" << dendl; + delete p; + return -EIO; + } + int r = p->try_connect(addr, opts); if (r < 0) { diff --git a/src/msg/async/rdma/RDMAStack.h b/src/msg/async/rdma/RDMAStack.h index dc01763ce34..7e225c8a915 100644 --- a/src/msg/async/rdma/RDMAStack.h +++ b/src/msg/async/rdma/RDMAStack.h @@ -176,7 +176,7 @@ class RDMAConnectedSocketImpl : public ConnectedSocketImpl { protected: CephContext *cct; - Infiniband::QueuePair *qp; + Infiniband::QueuePair *qp = nullptr; uint32_t peer_qpn = 0; uint32_t local_qpn = 0; int connected; From d5f14772184d782c604a7c96cb219c384d21f779 Mon Sep 17 00:00:00 2001 From: benhanokh Date: Fri, 12 Jun 2026 12:48:32 +0300 Subject: [PATCH 338/596] rgw/dedup: extend shard tiers, add overflow guard, and fix bucket filter The dedup subsystem was limited to 512 MD5 shards with 10% headroom, capping effective operation at ~4 billion objects. - Raise MAX_MD5_SHARD from 512 to 2048 and add new shard tiers for 1024 (2GB), 2048 (4GB) shards, supporting up to ~213 billion objects. - Increase headroom from 10% to 20% to improve hash table load factor. - Reject pools that exceed the maximum capacity with -EOVERFLOW - Apply bucket filter in collect_all_buckets_stats() so that filtered buckets do not inflate the object count used for shard sizing. - Update sizing table, comments, and documentation to reflect the new tiers and capacity limit. Signed-off-by: benhanokh --- doc/radosgw/s3_objects_dedup.rst | 24 ++++++++----- src/rgw/driver/rados/rgw_dedup.cc | 50 +++++++++++++++++++++----- src/rgw/driver/rados/rgw_dedup_utils.h | 2 +- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/doc/radosgw/s3_objects_dedup.rst b/doc/radosgw/s3_objects_dedup.rst index 132a02319b7..3c731766b76 100644 --- a/doc/radosgw/s3_objects_dedup.rst +++ b/doc/radosgw/s3_objects_dedup.rst @@ -158,19 +158,27 @@ Memory Usage +------------------+----------+ | RGW Object Count | Memory | +==================+==========+ - | 1M | 8 MB | + | 1M | 8 MB | +------------------+----------+ - | 4M | 16 MB | + | 4M | 16 MB | +------------------+----------+ - | 16M | 32 MB | + | 16M | 32 MB | +------------------+----------+ - | 64M | 64 MB | + | 64M | 64 MB | +------------------+----------+ - | 256M | 128 MB | + | 256M | 128 MB | +------------------+----------+ - | 1024M (1G) | 256 MB | + | 1024M (1G) | 256 MB | +------------------+----------+ - | 4096M (4G) | 512 MB | + | 4096M (4G) | 512 MB | +------------------+----------+ - | 16384M (16G) | 1024 MB | + | 16384M (16G) | 1024 MB | +------------------+----------+ + | 65536M (64G) | 2048 MB | + +------------------+----------+ + | 262144M (256G) | 4096 MB | + +------------------+----------+ + + .. note:: + Pools with more than ~213 billion user objects (256B with headroom) exceed the + dedup system's maximum capacity and will be rejected at startup. diff --git a/src/rgw/driver/rados/rgw_dedup.cc b/src/rgw/driver/rados/rgw_dedup.cc index 260a8315f5e..d9551159a48 100644 --- a/src/rgw/driver/rados/rgw_dedup.cc +++ b/src/rgw/driver/rados/rgw_dedup.cc @@ -2681,6 +2681,11 @@ namespace rgw::dedup { goto err; } ldpp_dout(dpp, 20) <<__func__ << "::bucket=" << bucket << dendl; + if (!d_filter.allow_bucket(bucket.name)) { + ldpp_dout(dpp, 10) << __func__ << "::skip bucket (filter): " + << bucket.name << dendl; + continue; + } ret = read_bucket_stats(bucket, &d_all_buckets_obj_count, &d_all_buckets_obj_size); if (unlikely(ret != 0)) { @@ -2944,14 +2949,22 @@ namespace rgw::dedup { //------------------------------------------------------------------------------- // 64M || 32 || 64MB || 64MB/32 = 2.00M * 32 = 64M || // 256M || 64 || 128MB || 128MB/32 = 4.00M * 64 = 256M || - // 1024M( 1G) || 128 || 256MB || 256MB/32 = 8.00M * 128 = 1024M || - // 4096M( 4G) || 256 || 512MB || 512MB/32 = 16M.00 * 256 = 4096M || - // 16384M(16G) || 512 || 1024MB || 1024MB/32 = 32M.00 * 512 = 16384M || + // 1024M( 1G)|| 128 || 256MB || 256MB/32 = 8.00M * 128 = 1024M || + // 4096M( 4G)|| 256 || 512MB || 512MB/32 = 16M.00 * 256 = 4096M || + //------------------------------------------------------------------------------- + // 16384M( 16G)|| 512 || 1024MB || 1024MB/32 = 32M.00 * 512 = 16384M || + // 65536M( 64G)|| 1024 || 2048MB || 2048MB/32 = 64M.00 *1024 = 65536M || + //262144M(256G)|| 2048 || 4096MB || 4096MB/32 =128M.00 *2048 =262144M || //-------------||--------------||---------||-----------------------------------|| + // > 256G || REJECTED || N/A || Pool exceeds max supported size || + //=============||==============||=========||===================================|| + static md5_shard_t calc_num_md5_shards(uint64_t obj_count) { - // create headroom by allocating space for a 10% bigger system - obj_count = obj_count + (obj_count/10); + // create headroom by allocating space for a 20% bigger system + // NOTE: all thresholds below compare against the inflated obj_count, + // not the raw count reported by pool stats. + obj_count = obj_count + (obj_count/5); uint64_t M = 1024 * 1024; if (obj_count < 1*M) { @@ -2979,12 +2992,26 @@ namespace rgw::dedup { return 128; } else if (obj_count < 4*1024*M) { - // less than 4096M objects -> use 256 shards (512MB) + // less than 4B objects -> use 256 shards (512MB) return 256; } - else { + else if (obj_count < 16*1024*M) { + // less than 16B objects -> use 512 shards (1GB) return 512; } + else if (obj_count < 64*1024*M) { + // less than 64B objects -> use 1024 shards (2GB) + return 1024; + } + else if (obj_count < 256*1024*M) { + // less than 256B objects -> use 2048 shards (4GB) + return 2048; + } + else { + // Dedup supports systems with up-to ~213 billion user objects per-pool + // With headroom that translates to about 256B objects + return MD5_SHARD_HARD_LIMIT + 1; + } } //--------------------------------------------------------------------------- @@ -2996,10 +3023,17 @@ namespace rgw::dedup { } md5_shard_t num_md5_shards = calc_num_md5_shards(d_all_buckets_obj_count); + if (num_md5_shards > MD5_SHARD_HARD_LIMIT) { + derr << __func__ << "::Pool has too many objects: (" + << d_all_buckets_obj_count << ") Max supported is 213 billion" << dendl; + return -EOVERFLOW; + } + num_md5_shards = std::min(num_md5_shards, MAX_MD5_SHARD); num_md5_shards = std::max(num_md5_shards, MIN_MD5_SHARD); work_shard_t num_work_shards = num_md5_shards; num_work_shards = std::min(num_work_shards, MAX_WORK_SHARD); + num_work_shards = std::max(num_work_shards, MIN_WORK_SHARD); ldpp_dout(dpp, 5) << __func__ << "::obj_count=" < Date: Sun, 21 Jun 2026 16:14:49 +0800 Subject: [PATCH 339/596] cmake,deb,rpm: exclude CI and test directories from installed modules The mgr module install rule already excluded tests/*, but that glob only dropped the files inside tests/ and left an empty tests/ directory in the installed tree. The ci/ directories, which hold Dockerfiles, e2e test scripts, and cluster specs used only for upstream CI pipelines, were not excluded at all and were shipped by ceph-mgr-dashboard and ceph-mgr-rook. Change the install pattern from tests/* to tests so the whole directory is pruned, and add a ci pattern. Both patterns live in the exclude list shared by every mgr module install rule (the module list, dashboard, and rook), so this covers all of them in one place. This supersedes the per-package excludes added in 8a2e0eb2fc and d74b88500b, so drop them from ceph.spec.in and debian/rules. Signed-off-by: Kefu Chai --- ceph.spec.in | 3 --- debian/rules | 5 +---- src/pybind/mgr/CMakeLists.txt | 3 ++- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index 186cacb9a86..b0fba106c2c 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -2345,7 +2345,6 @@ fi %files mgr-dashboard %{_datadir}/ceph/mgr/dashboard -%exclude %{_datadir}/ceph/mgr/dashboard/ci %ceph_mgr_module_scripts mgr-dashboard @@ -2466,7 +2465,6 @@ fi %files mgr-rook %{_datadir}/ceph/mgr/rook -%exclude %{_datadir}/ceph/mgr/rook/ci %ceph_mgr_module_scripts mgr-rook @@ -2477,7 +2475,6 @@ fi %files mgr-cephadm %{_datadir}/ceph/mgr/cephadm -%exclude %{_datadir}/ceph/mgr/cephadm/tests %ceph_mgr_module_scripts mgr-cephadm diff --git a/debian/rules b/debian/rules index d27dac6a664..48b8634a1c4 100755 --- a/debian/rules +++ b/debian/rules @@ -158,10 +158,7 @@ override_dh_python3: dh_python3 -p ceph-fuse --shebang=/usr/bin/python3 dh_python3 -p ceph-volume --shebang=/usr/bin/python3 -override_dh_install: - dh_install --exclude=mgr/cephadm/tests --exclude=mgr/dashboard/ci --exclude=mgr/rook/ci - # do not run tests override_dh_auto_test: -.PHONY: override_dh_autoreconf override_dh_auto_configure override_dh_auto_clean override_dh_auto_install override_dh_installlogrotate override_dh_installinit override_dh_install override_dh_strip override_dh_auto_test +.PHONY: override_dh_autoreconf override_dh_auto_configure override_dh_auto_clean override_dh_auto_install override_dh_installlogrotate override_dh_installinit override_dh_strip override_dh_auto_test diff --git a/src/pybind/mgr/CMakeLists.txt b/src/pybind/mgr/CMakeLists.txt index 9edfe705dee..342b2b942d7 100644 --- a/src/pybind/mgr/CMakeLists.txt +++ b/src/pybind/mgr/CMakeLists.txt @@ -4,7 +4,8 @@ set(mgr_module_install_excludes PATTERN "tox.ini" EXCLUDE PATTERN "requirements*.txt" EXCLUDE PATTERN "constraints*.txt" EXCLUDE - PATTERN "tests/*" EXCLUDE) + PATTERN "tests" EXCLUDE + PATTERN "ci" EXCLUDE) add_subdirectory(dashboard) From ecd883a1b3924b981042df3903cb1dc6b55d5920 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 22 Jun 2026 07:44:20 +0800 Subject: [PATCH 340/596] librbd: fix use-after-free releasing object map locks in deep copy DeepCopyRequest::send_copy_object_map() holds the destination image's owner_lock and image_lock (shared), issues an asynchronous object_map->rollback(), and then releases the two locks through m_dst_image_ctx. The rollback callback drives the rest of the request to completion (handle_copy_object_map() ... finish() -> put()) on other threads. That path needs image_lock, so it only proceeds once this method releases it, and can then run to completion and free 'this' before the following owner_lock.unlock_shared() executes, dereferencing the freed DeepCopyRequest. ASan reported this as a heap-use-after-free in TestImageReplayer.StartReplayAndWrite: the object was freed on the finisher thread (handle_copy_metadata() -> finish()) while the io_context thread was still in send_copy_object_map(). The destination image ctx outlives the request, so operate through a local reference to it and release the locks through that reference rather than through 'this', as operation/SnapshotRollbackRequest::send_rollback_object_map() already does. Keep the explicit lock_shared()/unlock_shared() calls rather than scoped std::shared_lock guards: unlike operation/SnapshotRollbackRequest::send_rollback_object_map(), send_copy_object_map() has four exit paths (send_copy_metadata(), send_refresh_object_map(), finish(), and the object map rollback), each of which must release the locks before running a different continuation, so RAII guards would push that continuation out of the locked scope and read less clearly than unlocking at each exit. Fixes: https://tracker.ceph.com/issues/77551 Signed-off-by: Kefu Chai --- src/librbd/DeepCopyRequest.cc | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/librbd/DeepCopyRequest.cc b/src/librbd/DeepCopyRequest.cc index 6878f145f26..934dad4a4ba 100644 --- a/src/librbd/DeepCopyRequest.cc +++ b/src/librbd/DeepCopyRequest.cc @@ -197,36 +197,38 @@ void DeepCopyRequest::handle_copy_image(int r) { template void DeepCopyRequest::send_copy_object_map() { - m_dst_image_ctx->owner_lock.lock_shared(); - m_dst_image_ctx->image_lock.lock_shared(); + // local ref so the unlocks don't touch 'this', which rollback() may free + I &dst_image_ctx = *m_dst_image_ctx; + dst_image_ctx.owner_lock.lock_shared(); + dst_image_ctx.image_lock.lock_shared(); - if (!m_dst_image_ctx->test_features(RBD_FEATURE_OBJECT_MAP, - m_dst_image_ctx->image_lock)) { - m_dst_image_ctx->image_lock.unlock_shared(); - m_dst_image_ctx->owner_lock.unlock_shared(); + if (!dst_image_ctx.test_features(RBD_FEATURE_OBJECT_MAP, + dst_image_ctx.image_lock)) { + dst_image_ctx.image_lock.unlock_shared(); + dst_image_ctx.owner_lock.unlock_shared(); send_copy_metadata(); return; } if (m_src_snap_id_end == CEPH_NOSNAP) { - m_dst_image_ctx->image_lock.unlock_shared(); - m_dst_image_ctx->owner_lock.unlock_shared(); + dst_image_ctx.image_lock.unlock_shared(); + dst_image_ctx.owner_lock.unlock_shared(); send_refresh_object_map(); return; } - ceph_assert(m_dst_image_ctx->object_map != nullptr); + ceph_assert(dst_image_ctx.object_map != nullptr); ldout(m_cct, 20) << dendl; Context *finish_op_ctx = nullptr; int r; - if (m_dst_image_ctx->exclusive_lock != nullptr) { - finish_op_ctx = m_dst_image_ctx->exclusive_lock->start_op(&r); + if (dst_image_ctx.exclusive_lock != nullptr) { + finish_op_ctx = dst_image_ctx.exclusive_lock->start_op(&r); } if (finish_op_ctx == nullptr) { lderr(m_cct) << "lost exclusive lock" << dendl; - m_dst_image_ctx->image_lock.unlock_shared(); - m_dst_image_ctx->owner_lock.unlock_shared(); + dst_image_ctx.image_lock.unlock_shared(); + dst_image_ctx.owner_lock.unlock_shared(); finish(r); return; } @@ -238,9 +240,9 @@ void DeepCopyRequest::send_copy_object_map() { }); ceph_assert(m_snap_seqs->count(m_src_snap_id_end) > 0); librados::snap_t copy_snap_id = (*m_snap_seqs)[m_src_snap_id_end]; - m_dst_image_ctx->object_map->rollback(copy_snap_id, ctx); - m_dst_image_ctx->image_lock.unlock_shared(); - m_dst_image_ctx->owner_lock.unlock_shared(); + dst_image_ctx.object_map->rollback(copy_snap_id, ctx); + dst_image_ctx.image_lock.unlock_shared(); + dst_image_ctx.owner_lock.unlock_shared(); } template From 82162c04bb714610f5ec6dbaa4f233eb0493d997 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 22 Jun 2026 00:14:55 +0800 Subject: [PATCH 341/596] cmake: use the libc allocator for sanitizer builds tcmalloc/jemalloc keep exporting the global operator new/delete even though their malloc is shadowed by the sanitizer interceptor, so memory the sanitizer allocated gets freed through tcmalloc and SIGSEGVs (e.g. seastar coroutine frames). Force libc when WITH_ASAN is set. Signed-off-by: Sun Yuechi --- CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 46a0efc680d..6de3055dc4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -460,6 +460,10 @@ if(ALLOCATOR) elseif(NOT ALLOCATOR STREQUAL "libc") message(FATAL_ERROR "Unsupported allocator selected: ${ALLOCATOR}") endif() +elseif(WITH_ASAN) + # Force libc: tcmalloc/jemalloc still export operator new/delete even when the + # sanitizer shadows malloc, so sanitizer-allocated memory is freed via tcmalloc and SIGSEGVs. + set(ALLOCATOR "libc") else(ALLOCATOR) find_package(gperftools 2.6.2) set(HAVE_LIBTCMALLOC ${gperftools_FOUND}) From 1089fa3a6fd8d64685909ca4ef146f68843f0334 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 21 Jun 2026 16:42:54 +0800 Subject: [PATCH 342/596] crimson: build seastar with the default allocator under ASan With this build configured as RelWithDebInfo, seastar keeps its own allocator instead of falling back to libc's. Under ASan that allocator is called (via dlsym) before it is initialized and SIGSEGVs every seastar/crimson unittest before main(). Define SEASTAR_DEFAULT_ALLOCATOR under WITH_ASAN to keep seastar on the libc allocator. Signed-off-by: Sun Yuechi --- src/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a93a38d7501..8b0cd023cfc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -510,6 +510,11 @@ if(WITH_CRIMSON) "-Wno-attributes" "-Wno-pessimizing-move" "-Wno-address-of-packed-member") + if(WITH_ASAN) + # As built here (RelWithDebInfo) seastar keeps its own allocator, which + # ASan calls via dlsym before init, crashing every test before main(). + target_compile_definitions(seastar PUBLIC SEASTAR_DEFAULT_ALLOCATOR) + endif() add_subdirectory(crimson) add_dependencies(crimson-osd erasure_code) endif() From 3ba08f88ade0cff63783ffcdfc238cbe7d91f16d Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 21 Jun 2026 16:42:54 +0800 Subject: [PATCH 343/596] test/encoding: probe `setarch -R` and drop the arch argument readable.sh wraps ceph-dencoder with `setarch $(uname -m) -R` to disable ASLR on ASan builds, but the arch-qualified form also sets the personality to that arch, which fails where setarch can't (e.g. riscv64). Use bare `setarch -R` to only clear ASLR, and probe it first so the script falls back to running ceph-dencoder unwrapped. Signed-off-by: Sun Yuechi --- src/test/encoding/readable.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/encoding/readable.sh b/src/test/encoding/readable.sh index 115d6d7b6f1..dcf53d2c8ae 100755 --- a/src/test/encoding/readable.sh +++ b/src/test/encoding/readable.sh @@ -24,9 +24,16 @@ fi # ASAN builds need setarch -R to disable ASLR so each process can find a # contiguous 16+ TB shadow memory region. See https://clang.llvm.org/docs/AddressSanitizer.html +# Use bare `setarch -R` (no arch): the arch-qualified form also sets the +# personality, which fails where setarch can't (e.g. riscv64). Check it works +# before wrapping, and just run ceph-dencoder unwrapped if it doesn't. if ldd $(command -v $CEPH_DENCODER) 2>/dev/null | grep -q libasan; then - echo "ASAN build detected: wrapping ceph-dencoder with 'setarch \$(uname -m) -R'" - CEPH_DENCODER="setarch $(uname -m) -R $CEPH_DENCODER" + if setarch -R true >/dev/null 2>&1; then + echo "ASAN build detected: wrapping ceph-dencoder with 'setarch -R'" + CEPH_DENCODER="setarch -R $CEPH_DENCODER" + else + echo "ASAN build detected but 'setarch -R' is unavailable; running ceph-dencoder without ASLR disable" + fi fi myversion=$($CEPH_DENCODER version) From 5166ec6f064fcc9a3299022ccebfdf69c8f6ec93 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sat, 20 Jun 2026 15:17:26 +0800 Subject: [PATCH 344/596] cmake: define BOOST_USE_UCONTEXT tree-wide under ASan Under WITH_ASAN Boost.Context is built ucontext-only, so consumers that include its headers without linking Boost::context (e.g. libosd) were still built for the fcontext backend and broke the link: mold: error: undefined symbol: boost::context::detail::make_fcontext Define the backend tree-wide so every consumer agrees on it. riscv64's ASan runtime mis-handles makecontext/swapcontext, so the ucontext fiber backend reports false-positive heap-buffer-overflows on fiber switch that can't be suppressed. So exclude it. Signed-off-by: Sun Yuechi --- CMakeLists.txt | 11 +++++++++++ cmake/modules/BuildBoost.cmake | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 46a0efc680d..99e389ff3e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -817,6 +817,17 @@ else() include(BuildBoost) build_boost(1.87 COMPONENTS ${BOOST_COMPONENTS} ${BOOST_HEADER_COMPONENTS}) + if(WITH_ASAN) + # Boost.Context is built ucontext-only here (context-impl=ucontext); define + # the matching backend tree-wide so every Boost.Context/Coroutine2 consumer + # agrees on it, instead of relying on per-target propagation that is easy to miss. + # Except riscv64: its ASan mis-handles ucontext, so it keeps fcontext. + if(CMAKE_SYSTEM_PROCESSOR MATCHES "riscv") + add_compile_definitions(BOOST_USE_ASAN) + else() + add_compile_definitions(BOOST_USE_ASAN BOOST_USE_UCONTEXT) + endif() + endif() endif() include_directories(BEFORE SYSTEM ${Boost_INCLUDE_DIRS}) add_library(Boost::asio INTERFACE IMPORTED) diff --git a/cmake/modules/BuildBoost.cmake b/cmake/modules/BuildBoost.cmake index d47e1eeb471..6f409dcdce1 100644 --- a/cmake/modules/BuildBoost.cmake +++ b/cmake/modules/BuildBoost.cmake @@ -146,7 +146,8 @@ function(do_build_boost root_dir version) endif() set(b2_targets headers stage) set(b2_install_targets install) - if(WITH_ASAN) + # Except riscv64: its ASan mis-handles ucontext, so it keeps fcontext. + if(WITH_ASAN AND NOT (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv")) list(APPEND b2 context-impl=ucontext) # `context-impl` is declared in libs/context/build/Jamfile.v2; the headers/stage # and install targets never load it, so b2 aborts with `unknown feature @@ -262,10 +263,8 @@ macro(build_boost version) set_target_properties(Boost::${c} PROPERTIES INTERFACE_COMPILE_DEFINITIONS "BOOST_USE_VALGRIND") endif() - if((c MATCHES "context") AND (WITH_ASAN)) - set_target_properties(Boost::${c} PROPERTIES - INTERFACE_COMPILE_DEFINITIONS "BOOST_USE_ASAN;BOOST_USE_UCONTEXT") - endif() + # ASan's BOOST_USE_ASAN/BOOST_USE_UCONTEXT are defined tree-wide in the + # top-level CMakeLists.txt, not per-target. list(APPEND Boost_LIBRARIES ${Boost_${upper_c}_LIBRARY}) endforeach() foreach(c ${Boost_BUILD_COMPONENTS}) From 0a4edc5213ba90f29db908996e095733a5e0572e Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 21 Jun 2026 16:42:54 +0800 Subject: [PATCH 345/596] vstart: load lsan/asan suppressions on WITH_ASAN builds AddCephTest.cmake runs unittests with ASAN_OPTIONS/LSAN_OPTIONS=suppressions=qa/{asan,lsan}.supp, but vstart.sh does not, so on a WITH_ASAN build `ceph-mon --mkfs` aborts on a still-reachable leak that those suppressions cover and fails the "ceph API tests" job. Export the same options when WITH_ASAN=ON. Signed-off-by: Sun Yuechi --- src/vstart.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/vstart.sh b/src/vstart.sh index 7f5421bffce..9e5e6cd3c68 100755 --- a/src/vstart.sh +++ b/src/vstart.sh @@ -64,6 +64,14 @@ if [ -e CMakeCache.txt ]; then CEPH_ROOT=$(get_cmake_variable ceph_SOURCE_DIR) CEPH_BUILD_DIR=`pwd` [ -z "$MGR_PYTHON_PATH" ] && MGR_PYTHON_PATH=$CEPH_ROOT/src/pybind/mgr + + # Point the sanitizers at the in-tree suppression files so vstart daemons + # ignore the same still-reachable third-party leaks AddCephTest.cmake suppresses for + # unittests. Without this `ceph-mon --mkfs` aborts on LeakSanitizer. + if [ "$(get_cmake_variable WITH_ASAN)" = "ON" ]; then + [ -z "$ASAN_OPTIONS" ] && export ASAN_OPTIONS="suppressions=$CEPH_ROOT/qa/asan.supp,detect_odr_violation=0" + [ -z "$LSAN_OPTIONS" ] && export LSAN_OPTIONS="suppressions=$CEPH_ROOT/qa/lsan.supp,print_suppressions=0" + fi fi # use CEPH_BUILD_ROOT to vstart from a 'make install' From e35cdb71232c69d20ddb5e2f0104f48c20fa5d86 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 22 Jun 2026 14:52:22 +0800 Subject: [PATCH 346/596] qa/lsan.supp: suppress still-reachable third-party leaks Several unittests fail LeakSanitizer on still-reachable allocations that belong to third-party libraries, not to Ceph. These are boost.thread's main-thread TLS, OpenSSL's one-time init (the ForkDeathTest children _exit() before it is freed), and the cipher, DRBG and error-stack state that OpenSSL and libcryptsetup keep behind the librbd encryption and migration unittests. None get freed without OPENSSL_cleanup(), so suppress them by their allocation entry points. Signed-off-by: Sun Yuechi --- qa/lsan.supp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/qa/lsan.supp b/qa/lsan.supp index 329389e5b5f..8dd1faa5e81 100644 --- a/qa/lsan.supp +++ b/qa/lsan.supp @@ -48,3 +48,22 @@ leak:^ceph::ErasureCodePluginRegistry::load # SQLite internal allocations # These are indirect leaks from SQLite's internal memory management leak:libsqlite3.so + +# boost.thread main-thread TLS; freed only when a boost-created thread exits, so +# still reachable at exit. Benign. +leak:boost::detail::make_external_thread_data + +# OpenSSL one-time init; ForkDeathTest children _exit() without OPENSSL_cleanup. +leak:^OPENSSL_init_crypto + +# Still-reachable OpenSSL / libcryptsetup state with no API to free at exit; +# seen in the librbd encryption and migration unittests under ASan. +# +# libcryptsetup OpenSSL cipher/per-thread state that survives crypt_free(). +leak:libcryptsetup +# OpenSSL DRBG global state, freed only by OPENSSL_cleanup (never called here). +leak:^RAND_bytes_ex +leak:^RAND_priv_bytes_ex +# OpenSSL per-thread error stack/strings, freed only by OPENSSL_thread_stop. +leak:^ossl_err_get_state_int +leak:^ERR_add_error_vdata From 51a82211edc43c2612b75e84eea7cdeee978e777 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 22 Jun 2026 14:52:57 +0800 Subject: [PATCH 347/596] qa/lsan.supp: suppress seastore reactor-teardown leaks unittest-seastore runs the seastar reactor on a separate thread (SeastarRunner) and stops it at exit without draining its pending tasks, so a few cached extents those tasks still held are leaked at shutdown. Suppress them by the three seastore subsystems they come from. Signed-off-by: Sun Yuechi --- qa/lsan.supp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qa/lsan.supp b/qa/lsan.supp index 8dd1faa5e81..e1a20fdccd8 100644 --- a/qa/lsan.supp +++ b/qa/lsan.supp @@ -67,3 +67,11 @@ leak:^RAND_priv_bytes_ex # OpenSSL per-thread error stack/strings, freed only by OPENSSL_thread_stop. leak:^ossl_err_get_state_int leak:^ERR_add_error_vdata + +# unittest-seastore stops the seastar reactor (SeastarRunner, on its own thread) +# without draining its pending tasks, so a few cached extents those tasks still +# held are leaked at shutdown. Three anchors for the three seastore subsystems +# they come from. +leak:crimson::os::seastore::Cache:: +leak:crimson::os::seastore::omap_manager +leak:crimson::os::seastore::FixedKVBtree From d123f74bb2f5c699a6a61314e430a89f2c0768f4 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 22 Jun 2026 15:25:18 +0800 Subject: [PATCH 348/596] doc/rados/operations: document the kernel-client min-compat holdout A kernel CephFS or RBD mount can keep ``set-require-min-compat-client reef`` from succeeding: the kernel client does not advertise the pg-upmap-primary feature, so ``ceph features`` reports it as a luminous client even when the daemons and every userspace client are newer. ceph-fuse, libcephfs, and librbd advertise the full feature set and are not affected. Document this, and add a section on finding the holdout with ``ceph features`` and switching it to a userspace client. Signed-off-by: Kefu Chai --- .../operations/require-min-compat-client.rst | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/doc/rados/operations/require-min-compat-client.rst b/doc/rados/operations/require-min-compat-client.rst index dbfa6204f5d..ad5cb938d05 100644 --- a/doc/rados/operations/require-min-compat-client.rst +++ b/doc/rados/operations/require-min-compat-client.rst @@ -112,7 +112,10 @@ The Linux kernel Ceph client (``krbd`` for RBD block devices, ``kcephfs`` for CephFS) ships with the kernel, not with Ceph packages, so its version is independent of the cluster release. New OSD map features reach the kernel client only after they are implemented and merged upstream, which -can lag the Ceph daemon release by months or years. +can lag the Ceph daemon release by months or years. The userspace clients +(``ceph-fuse`` and ``libcephfs`` for CephFS, ``librbd`` for RBD) link the +Ceph release they were built from and advertise its full feature set, so +they are not subject to this lag. The *Min kernel* column shows the minimum Linux kernel version required on kernel client hosts before the corresponding commands are used. If no @@ -164,6 +167,21 @@ kernel clients are present in the cluster, that column can be ignored. See :ref:`read_balancer` for removal commands. +When a kernel mount blocks the flag +=================================== + +A single kernel mount can hold the whole cluster below the release you +want, even when every daemon and userspace client is already newer. +``ceph features`` reports such a mount as ``luminous`` because the kernel +client does not advertise the ``pg-upmap-primary`` (reef) feature. To +confirm which entry it adds, run ``ceph features`` with the mount +unmounted, then again with it mounted, and compare the client counts. + +Since the userspace clients advertise the full feature set, switching the +holdout to its userspace equivalent (``ceph-fuse`` or ``libcephfs`` for +CephFS, ``librbd`` for RBD) clears the blocker, after which the flag can +be raised and the read balancer enabled. + See :ref:`upmap` and :ref:`read_balancer` for the operational procedures that depend on raising the flag. CephFS clients must satisfy both this flag and any per-filesystem :ref:`cephfs_required_client_features` on From 8ab6f707ced8c3dea32afffbb3ff8be1740adf31 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Thu, 18 Jun 2026 13:18:47 +0800 Subject: [PATCH 349/596] crimson/os/seastore/transaction_manager: refresh the indirect_cursor after removing the direct_cursor in _remove() Fixes: https://tracker.ceph.com/issues/77485 Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/transaction_manager.cc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/crimson/os/seastore/transaction_manager.cc b/src/crimson/os/seastore/transaction_manager.cc index f0e586220b9..9a66a80d3b8 100644 --- a/src/crimson/os/seastore/transaction_manager.cc +++ b/src/crimson/os/seastore/transaction_manager.cc @@ -331,6 +331,12 @@ TransactionManager::_remove( indirect_cursor = co_await lba_manager->update_mapping_refcount( t, mapping.indirect_cursor, -1); co_await mapping.direct_cursor->refresh(); + if (unlikely(indirect_cursor->get_key() == + mapping.direct_cursor->get_key())) { + // indirect_cursor points to the same mapping as direct_cursor, + // no need to keep it + indirect_cursor.reset(); + } } DEBUGT("removing direct mapping {}~0x{:x} refcount={} -- offset={}", @@ -345,6 +351,10 @@ TransactionManager::_remove( LBACursorRef direct_cursor = co_await lba_manager->update_mapping_refcount( t, mapping.direct_cursor, -1); + if (indirect_cursor) { + co_await indirect_cursor->refresh(); + } + auto ret = co_await resolve_cursor_to_mapping( t, indirect_cursor ? std::move(indirect_cursor) : std::move(direct_cursor) From 01c0602b1d959f2559f66f21adaa6182dcbfe8fd Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Mon, 22 Jun 2026 20:47:12 +0800 Subject: [PATCH 350/596] crimson/os/seastore/lba: coroutinize LBACursor::refresh() Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/lba/lba_btree_node.cc | 95 +++++++++---------- 1 file changed, 45 insertions(+), 50 deletions(-) diff --git a/src/crimson/os/seastore/lba/lba_btree_node.cc b/src/crimson/os/seastore/lba/lba_btree_node.cc index ed94290f72a..5f64cba2217 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.cc +++ b/src/crimson/os/seastore/lba/lba_btree_node.cc @@ -87,60 +87,55 @@ LBALeafNode::internal_const_iterator_t LBALeafNode::insert( base_iertr::future<> LBACursor::refresh() { LOG_PREFIX(LBACursor::refresh); - return with_btree( - ctx.cache, - ctx, - [this, FNAME, c=ctx](auto &btree) { - c.trans.cursor_stats.num_refresh_parent_total++; - if (!parent->is_valid()) { - c.trans.cursor_stats.num_refresh_invalid_parent++; - SUBTRACET( - seastore_lba, - "cursor {} parent is invalid, re-search from scratch", - c.trans, *this); - return btree.lower_bound(c, this->get_laddr() - ).si_then([this](lba::LBABtree::iterator it) { - assert(this->get_laddr() == it.get_key()); - iter = LBALeafNode::iterator( - it.get_leaf_node().get(), - it.get_leaf_pos()); - auto leaf = it.get_leaf_node(); - parent = leaf; - modifications = leaf->modifications; - }); - } - assert(parent->is_stable() || - parent->is_pending_in_trans(c.trans.get_trans_id())); - auto leaf = parent->cast(); - if (leaf->is_pending_in_trans(c.trans.get_trans_id())) { - if (leaf->modified_since(modifications)) { - c.trans.cursor_stats.num_refresh_modified_viewable_parent++; - } else { - // no need to refresh - return base_iertr::now(); - } - } else { - auto [viewable, l] = leaf->resolve_transaction(c.trans, get_laddr()); - SUBTRACET(seastore_lba, "cursor: {} viewable: {}", - c.trans, *this, viewable); - if (!viewable) { - leaf = l; - c.trans.cursor_stats.num_refresh_unviewable_parent++; - parent = leaf; - } else { - assert(leaf.get() == l.get()); - assert(leaf->is_stable()); - return base_iertr::now(); - } - } + auto btree = co_await get_btree(ctx.cache, ctx); + ctx.trans.cursor_stats.num_refresh_parent_total++; + if (!parent->is_valid()) { + ctx.trans.cursor_stats.num_refresh_invalid_parent++; + SUBTRACET( + seastore_lba, + "cursor {} parent is invalid, re-search from scratch", + ctx.trans, *this); + lba::LBABtree::iterator it = co_await btree.lower_bound( + ctx, this->get_laddr()); + assert(this->get_laddr() == it.get_key()); + iter = LBALeafNode::iterator( + it.get_leaf_node().get(), + it.get_leaf_pos()); + auto leaf = it.get_leaf_node(); + parent = leaf; modifications = leaf->modifications; - iter = leaf->lower_bound(get_laddr()); - assert(is_viewable()); + co_return; + } + assert(parent->is_stable() || + parent->is_pending_in_trans(ctx.trans.get_trans_id())); + auto leaf = parent->cast(); + if (leaf->is_pending_in_trans(ctx.trans.get_trans_id())) { + if (leaf->modified_since(modifications)) { + ctx.trans.cursor_stats.num_refresh_modified_viewable_parent++; + } else { + // no need to refresh + co_return; + } + } else { + auto [viewable, l] = leaf->resolve_transaction(ctx.trans, get_laddr()); + SUBTRACET(seastore_lba, "cursor: {} viewable: {}", + ctx.trans, *this, viewable); + if (!viewable) { + leaf = l; + ctx.trans.cursor_stats.num_refresh_unviewable_parent++; + parent = leaf; + } else { + assert(leaf.get() == l.get()); + assert(leaf->is_stable()); + co_return; + } + } - return base_iertr::now(); - }); + modifications = leaf->modifications; + iter = leaf->lower_bound(get_laddr()); + assert(is_viewable()); } } From 4d29f4e4021081f19a304dea6938b6f4350eb748 Mon Sep 17 00:00:00 2001 From: Sridhar Seshasayee Date: Wed, 6 May 2026 20:41:33 +0530 Subject: [PATCH 351/596] osd/PeeringState: add perf counters for PG rebuild times Track per-OSD PG rebuild duration in prepare_stats_for_publish() (primary OSD only). Three new counters are added to the recoverystate_perf collection: - pg_rebuild_duration: LONGRUNAVG time counter (sum+count pair); This counter internally maintains 'avgcount' that tracks the cumulative number of rebuild events. - pg_rebuild_max_secs: maximum rebuild duration observed in seconds - pg_rebuild_min_secs: minimum rebuild duration observed in seconds The logic uses a per-PG in-memory latch (rebuild_start_time) to capture the redundancy failure entry point. When a PG is first observed to be in a vulnerable state (PG_STATE_DEGRADED, PG_STATE_UNDERSIZED, or num_objects_misplaced/degraded > 0), it latches info.stats.last_change as the start time, provided last_change > last_clean, which ensures only genuine new failures after the prior clean interval are tracked. On recovery, the rebuild duration is computed as (now - rebuild_start_time) and is only recorded if delta num_objects_recovered > 0 or the PG had confirmed redundancy loss at latch time, filtering out spurious state transitions. The latch is cleared after each recorded event. The latch is also cleared in clear_primary_state() so that an interval change or role transition (primary -> replica) does not carry a stale start time or baseline recovered count into a future interval. The new last_degraded is intentionally not used here to retain compatibility with older Ceph branches where the field doesn't exist and requires encoding changes. A future simplification can replace the latch with a direct (last_clean - last_degraded) calculation once last_degraded is consistently available. This interim solution is a close approximation of the PG rebuild time. These counters are scraped per-OSD by ceph-exporter and exposed to Prometheus, enabling durability score calculations over user-defined time windows. Other Changes: 1. Add unit tests to TestPeeringState.cc that exercise the latch logic. 2. Add a standalone integration test to verify that the rebuild perf counters are incremented on the primary OSD after a recovery event. Fixes: https://tracker.ceph.com/issues/77493 Signed-off-by: Sridhar Seshasayee --- qa/standalone/osd/osd-recovery-stats.sh | 81 ++++++ src/osd/PeeringState.cc | 93 +++++++ src/osd/PeeringState.h | 22 ++ src/osd/osd_perf_counters.cc | 9 + src/osd/osd_perf_counters.h | 3 + src/test/osd/TestPeeringState.cc | 342 ++++++++++++++++++++++++ 6 files changed, 550 insertions(+) diff --git a/qa/standalone/osd/osd-recovery-stats.sh b/qa/standalone/osd/osd-recovery-stats.sh index 6a402d4914f..4d780ccbcb3 100755 --- a/qa/standalone/osd/osd-recovery-stats.sh +++ b/qa/standalone/osd/osd-recovery-stats.sh @@ -716,6 +716,87 @@ function TEST_recovery_last_degraded_undersized() { kill_daemons $dir || return 1 } +# Verify that the rebuild perf counters on the primary OSD increment after a +# real EC shard recovery. Kill one non-primary OSD so the PG goes degraded, +# then let recovery run to completion. After the PG is clean again: +# - pg_rebuild_duration.avgcount must be >= 1 +# - pg_rebuild_duration.sum must be > 0 (real time elapsed) +function TEST_rebuild_perf_ec_increments() { + local dir=$1 + local OSDS=4 + local ecpoolname=ectest + + run_mon $dir a || return 1 + run_mgr $dir x || return 1 + for osd in $(seq 0 $(expr $OSDS - 1)) + do + run_osd $dir $osd --osd-mclock-skip-benchmark=true || return 1 + done + + ceph osd erasure-code-profile set ecprofile \ + plugin=jerasure technique=reed_sol_van k=2 m=1 \ + crush-failure-domain=osd || return 1 + ceph osd pool create $ecpoolname 1 1 erasure ecprofile || return 1 + ceph osd pool set $ecpoolname min_size 2 || return 1 + wait_for_clean || return 1 + + # Write a few objects so the PG has data that must be recovered. + for i in $(seq 1 5) + do + rados -p $ecpoolname put obj$i /etc/hostname || return 1 + done + wait_for_clean || return 1 + + local primary + primary=$(get_primary $ecpoolname obj1) + local replica + replica=$(get_not_primary $ecpoolname obj1) + + # Pause recovery so the PG stays degraded long enough for the latch to + # fire inside prepare_stats_for_publish before recovery completes. + ceph osd set norecover || return 1 + + # Kill one non-primary OSD so the PG becomes degraded. + kill $(cat $dir/osd.${replica}.pid) + ceph osd down osd.${replica} || return 1 + ceph osd out osd.${replica} || return 1 + + # Release the hold and wait for full recovery. + ceph osd unset norecover || return 1 + wait_for_clean || return 1 + + # flush_pg_stats triggers publish_stats_to_osd on every OSD, which calls + # prepare_stats_for_publish and commits the rebuild counters. + flush_pg_stats || return 1 + + # The primary may be the same OSD we started with (we only killed a + # replica), but re-query in case CRUSH remapped the primary shard. + primary=$(get_primary $ecpoolname obj1) + + local dump + dump=$(CEPH_ARGS='' ceph --admin-daemon $(get_asok_path osd.${primary}) \ + perf dump) || return 1 + + local rebuild_avgcount + rebuild_avgcount=$(jq '.recoverystate_perf.pg_rebuild_duration.avgcount' \ + <<< "$dump") + test "$rebuild_avgcount" -ge 1 || { + echo "FAIL: expected pg_rebuild_duration.avgcount>=1, got $rebuild_avgcount" + return 1 + } + + echo "$dump" | \ + jq -e '.recoverystate_perf.pg_rebuild_duration.sum > 0' > /dev/null || { + local rebuild_sum + rebuild_sum=$(jq '.recoverystate_perf.pg_rebuild_duration.sum' <<< "$dump") + echo "FAIL: expected pg_rebuild_duration.sum>0, got $rebuild_sum" + return 1 + } + + delete_pool $ecpoolname + kill_daemons $dir || return 1 +} + main osd-recovery-stats "$@" # Local Variables: diff --git a/src/osd/PeeringState.cc b/src/osd/PeeringState.cc index ba093f648b4..c3ed6be8107 100644 --- a/src/osd/PeeringState.cc +++ b/src/osd/PeeringState.cc @@ -1062,6 +1062,10 @@ void PeeringState::clear_primary_state() clear_recovery_state(); + rebuild_start_time = utime_t(); + rebuild_base_recovered = 0; + rebuild_had_redundancy_loss = false; + pg_committed_to = eversion_t(); missing_loc.clear(); pl->clear_primary_state(); @@ -4498,6 +4502,95 @@ std::optional PeeringState::prepare_stats_for_publish( if ((info.stats.state & PG_STATE_UNDERSIZED) == 0) info.stats.last_fullsized = now; + /** + * The following block is an interim solution to aggregate PG rebuild stats + * into a set of perf counters. The counterss are set based on the following + * existing pg_stat_t fields: + * - last_clean, last_change + * - num_objects_degraded, num_objects_misplaced, num_objects_recovered + * + * The PG rebuild stats are aggregated into the following recoverystate + * perf counters: + * - rs_pg_rebuild_duration: rebuild duration LONGRUNAVG time counter + * - rs_pg_rebuild_max_secs: maximum rebuild duration (secs) + * - rs_pg_rebuild_min_secs: minimum rebuild duration (secs) + * + * Workflow: + * 1. Only the acting primary OSD of the PG executes the logic to + * determine the rebuild stats. + * 2. The logic uses rebuild_start_time as a per-PG in-memory latch that + * captures the failure entry point. When a PG is considered vulnerable, + * rebuild_start_time latches info.stats.last_change as the start time, + * provided last_change > last_clean, which ensures only genuine new + * failures after the prior clean interval are tracked. + * 3. On recovery, the rebuild duration is computed as + * (now - rebuild_start_time) and is only recorded if delta + * num_objects_recovered > 0 or the PG had confirmed redundancy loss + * at latch time, filtering out spurious state transitions. The latch + * is cleared after each recorded event. + * + * last_degraded is intentionally not used in the interim solution to + * retain compatibility with older Ceph releases where this field doesn't + * exist and requires encoding changes. The interim solution is a + * close approximation of the PG rebuild time. + * + * A future simplification can replace the latch with a direct + * (last_clean - last_degraded) calculation once last_degraded is + * consistently available. + */ + if (is_primary()) { + const int64_t num_degraded = info.stats.stats.sum.num_objects_degraded; + const int64_t num_misplaced = info.stats.stats.sum.num_objects_misplaced; + const int64_t num_recovered = info.stats.stats.sum.num_objects_recovered; + const bool is_vulnerable = + (info.stats.state & (PG_STATE_DEGRADED | PG_STATE_UNDERSIZED)) || + num_degraded > 0 || num_misplaced > 0; + + if (is_vulnerable) { + // Latch the failure entry point on the first publish after a new + // failure; last_change captures when the state transition occurred. + if (rebuild_start_time == utime_t()) { + const bool new_failure = + info.stats.last_clean == utime_t() || + info.stats.last_change > info.stats.last_clean; + if (new_failure) { + rebuild_start_time = info.stats.last_change; + rebuild_base_recovered = num_recovered; + rebuild_had_redundancy_loss = + (num_degraded > 0 || num_misplaced > 0); + psdout(15) << "rebuild-stats: latched failure start for " + << info.pgid << " at " << rebuild_start_time << dendl; + } + } + } else if (rebuild_start_time != utime_t()) { + // PG recovered — record rebuild time if this was a genuine event. + const int64_t delta_recovered = num_recovered - rebuild_base_recovered; + const utime_t rebuild_dur = now - rebuild_start_time; + + if (rebuild_dur.to_msec() > 0 && + (delta_recovered > 0 || rebuild_had_redundancy_loss)) { + PerfCounters &perf = pl->get_peering_perf(); + perf.tinc(rs_pg_rebuild_duration, rebuild_dur); + + const uint64_t rebuild_secs = (uint64_t)rebuild_dur.sec(); + if (rebuild_secs > perf.get(rs_pg_rebuild_max_secs)) { + perf.set(rs_pg_rebuild_max_secs, rebuild_secs); + } + const uint64_t cur_min = perf.get(rs_pg_rebuild_min_secs); + if (cur_min == 0 || rebuild_secs < cur_min) { + perf.set(rs_pg_rebuild_min_secs, rebuild_secs); + } + psdout(15) << "rebuild-stats: recorded rebuild for " << info.pgid + << " duration=" << rebuild_dur + << " delta_recovered=" << delta_recovered << dendl; + } + // reset for the next event + rebuild_start_time = utime_t(); + rebuild_base_recovered = 0; + rebuild_had_redundancy_loss = false; + } + } + // check if the PG is vulnerable if (info.stats.state & (PG_STATE_DEGRADED|PG_STATE_UNDERSIZED)) { // set last_degraded only if we are entering a new diff --git a/src/osd/PeeringState.h b/src/osd/PeeringState.h index ae426060aba..94ee299cdfb 100644 --- a/src/osd/PeeringState.h +++ b/src/osd/PeeringState.h @@ -1579,6 +1579,17 @@ public: bool backfill_reserved = false; bool backfill_reserving = false; + /** + * Per-PG latch state for rebuild time tracking. Cleared after each + * completed rebuild event is recorded in the perf counters. + * The state is also cleared in clear_primary_state() so that an interval + * change or role transition (primary -> replica) does not carry a stale + * start time or baseline recovered count into a future interval. + */ + utime_t rebuild_start_time; + int64_t rebuild_base_recovered = 0; + bool rebuild_had_redundancy_loss = false; + PeeringMachine machine; void update_osdmap_ref(OSDMapRef newmap) { @@ -1680,6 +1691,17 @@ public: } } + // Accessors for the per-PG rebuild latch state. + utime_t get_rebuild_start_time() const { + return rebuild_start_time; + } + int64_t get_rebuild_base_recovered() const { + return rebuild_base_recovered; + } + bool get_rebuild_had_redundancy_loss() const { + return rebuild_had_redundancy_loss; + } + private: bool check_prior_readable_down_osds(const OSDMapRef& map); diff --git a/src/osd/osd_perf_counters.cc b/src/osd/osd_perf_counters.cc index 51d98431713..2fc483131ec 100644 --- a/src/osd/osd_perf_counters.cc +++ b/src/osd/osd_perf_counters.cc @@ -542,6 +542,15 @@ PerfCounters *build_recoverystate_perf(CephContext *cct) { rs_perf.add_u64_counter(rs_update_stats_invalidated, "update_stats_invalidated", "Number of times pg stats received invalidations during stats updates"); rs_perf.add_u64_counter(rs_append_log_stats_invalidated, "append_log_stats_invalidated", "Number of times pg stats received invalidations when appending new log entries"); rs_perf.add_u64_counter(rs_merge_log_stats_invalidated, "merge_log_stats_invalidated", "Number of times pg stats received invalidations during merging of log entries"); + rs_perf.add_time_avg(rs_pg_rebuild_duration, "pg_rebuild_duration", + "Average PG rebuild duration on this OSD (primary role only)", + NULL, PerfCountersBuilder::PRIO_USEFUL); + rs_perf.add_u64(rs_pg_rebuild_max_secs, "pg_rebuild_max_secs", + "Max PG rebuild duration seen on this OSD in seconds (primary role only)", + NULL, PerfCountersBuilder::PRIO_USEFUL); + rs_perf.add_u64(rs_pg_rebuild_min_secs, "pg_rebuild_min_secs", + "Min PG rebuild duration seen on this OSD in seconds (primary role only)", + NULL, PerfCountersBuilder::PRIO_USEFUL); return rs_perf.create_perf_counters(); } diff --git a/src/osd/osd_perf_counters.h b/src/osd/osd_perf_counters.h index 4b56b79215d..c8fccb95152 100644 --- a/src/osd/osd_perf_counters.h +++ b/src/osd/osd_perf_counters.h @@ -263,6 +263,9 @@ enum { rs_update_stats_invalidated, rs_append_log_stats_invalidated, rs_merge_log_stats_invalidated, + rs_pg_rebuild_duration, + rs_pg_rebuild_max_secs, + rs_pg_rebuild_min_secs, rs_last, }; diff --git a/src/test/osd/TestPeeringState.cc b/src/test/osd/TestPeeringState.cc index f77898f16a0..067456f4261 100644 --- a/src/test/osd/TestPeeringState.cc +++ b/src/test/osd/TestPeeringState.cc @@ -29,7 +29,9 @@ * PrimaryLogPG to allow this to be tested. */ +#include #include +#include #include #include "test/osd/MockConnection.h" #include "test/osd/MockECRecPred.h" @@ -1185,6 +1187,15 @@ protected: } } + // Helper - call prepare_stats_for_publish on an OSD, discarding the result. + // Passing nullopt forces the publish branch unconditionally (no last-known + // stat to compare against), which is what we need to drive the latch logic. + void call_prepare_stats(int osd) + { + get_ps(osd)->prepare_stats_for_publish( + std::nullopt, object_stat_collection_t()); + } + // ============================================================================ // GTest - Setup and Teardown // ============================================================================ @@ -2181,6 +2192,337 @@ TEST_F(PeeringStateTest, Issue74218) { verify_logs(); } +// ============================================================================ +// Rebuild Stats Perf Counter Tests +// +// These tests exercise the latch logic in prepare_stats_for_publish() that +// feeds rs_pg_rebuild_duration, rs_pg_rebuild_max_secs, and +// rs_pg_rebuild_min_secs. +// Design notes: +// - call_prepare_stats(osd) passes nullopt so the publish branch always +// runs, which is needed to drive the latch even when stats are unchanged. +// - A 10 ms sleep between the latch call and the clean call ensures +// rebuild_dur.to_msec() > 0 so the record is committed. +// - rebuild_secs = (uint64_t)rebuild_dur.sec(), so sub-second rebuilds +// leave pg_rebuild_max_secs and pg_rebuild_min_secs at 0. Those gauges +// are verified only for their mutual ordering (min <= max); absolute +// values are verified via pg_rebuild_duration.sum which is in nanoseconds. +// ============================================================================ + +// One complete failure+recovery cycle does the following: +// - Swaps acting[slot] from old_osd to new_osd. +// - Peers, latches (via call_prepare_stats while degraded). +// - Sleeps sleep_ms to guarantee non-zero rebuild duration. +// - Recovers, verifies clean, calls prepare_stats to commit the record. +// +// Acting set BEFORE call: [..., old_osd, ...] at position slot. +// Acting set AFTER call: [..., new_osd, ...] at position slot. +// The old_osd PeeringState is left in osd_peeringstate (may go stale). + +// ============================================================================ +// Test 1: Primary OSD records all four counters after a recovery event. +// ============================================================================ +TEST_F(PeeringStateTest, RebuildStatsLatchAndCount) { + dout(0) << "== RebuildStatsLatchAndCount ==" << dendl; + test_create_peering_state(); + test_init(); + test_event_initialize(); + eversion_t v = test_append_log_entry(); + test_peering(); + verify_all_active_clean(v, eversion_t()); + + // Stamp last_clean on the primary so new_failure detection works later. + call_prepare_stats(acting_primary); + + PerfCounters *perf = get_listener(acting_primary)->recoverystate_perf; + + // Introduce a missing replica: swap acting[1] from OSD 1 to OSD 9. + // OSD 9 starts fresh and needs the log entry recovered to it. + modify_up_acting(1, 9); + test_create_peering_state(9, 1); + test_init(9); + test_event_initialize(9); + test_peering(); + // PG is now active+recovering+degraded on the primary. + + // Latch: first prepare_stats call while vulnerable. + // The state-change block sets info.stats.last_change = now and + // rebuild_start_time = last_change inside the latch branch. + call_prepare_stats(acting_primary); + + // Sleep so that rebuild_dur.to_msec() > 0 when the record fires. + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + // Drive the PG back to active+clean. + test_begin_peer_recover(9, 1); + test_on_peer_recover(9, 1, v); + test_recover_got(9, v); + test_object_recovered(); + test_event_all_replicas_recovered(); + verify_all_active_clean(v, eversion_t()); + + // Record: prepare_stats while clean fires the else branch, commits record. + call_prepare_stats(acting_primary); + + // pg_rebuild_duration: at least one sample with a positive nanosecond sum. + auto [sum_ns, count] = perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_GE(count, 1u); + EXPECT_GT(sum_ns, 0u); + + // max/min gauges track whole seconds; sub-second rebuilds leave both at 0. + // The key invariant is that min never exceeds max. + EXPECT_LE(perf->get(rs_pg_rebuild_min_secs), perf->get(rs_pg_rebuild_max_secs)); +} + +// ============================================================================ +// Test 2: A replica OSD never records anything, even when the PG is degraded. +// ============================================================================ +TEST_F(PeeringStateTest, RebuildStatsReplicaSkips) { + dout(0) << "== RebuildStatsReplicaSkips ==" << dendl; + test_create_peering_state(); + test_init(); + test_event_initialize(); + eversion_t v = test_append_log_entry(); + test_peering(); + verify_all_active_clean(v, eversion_t()); + + call_prepare_stats(acting_primary); + + modify_up_acting(1, 9); + test_create_peering_state(9, 1); + test_init(9); + test_event_initialize(9); + test_peering(); + + // OSD 9 is the recovering replica. prepare_stats_for_publish() is only + // called by the primary. + PerfCounters *replica_perf = get_listener(9)->recoverystate_perf; + + // Drive the primary through the full latch+record cycle. + call_prepare_stats(acting_primary); // latch fires on primary + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + test_begin_peer_recover(9, 1); + test_on_peer_recover(9, 1, v); + test_recover_got(9, v); + test_object_recovered(); + test_event_all_replicas_recovered(); + verify_all_active_clean(v, eversion_t()); + + call_prepare_stats(acting_primary); // record fires on primary + + // All rebuild counters on the replica must remain at their initial values. + EXPECT_EQ(replica_perf->get(rs_pg_rebuild_max_secs), 0u); + EXPECT_EQ(replica_perf->get(rs_pg_rebuild_min_secs), 0u); + auto [sum_ns, count] = + replica_perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 0u); + EXPECT_EQ(sum_ns, 0u); + + // Primary must have recorded the event (sanity-check the other side). + auto [primary_sum_ns, primary_count] = + get_listener(acting_primary)->recoverystate_perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_GE(primary_count, 1u); +} + +// ============================================================================ +// Test 3: A second prepare_stats call while still vulnerable does not +// overwrite the already-latched start time or double-record the event. +// ============================================================================ +TEST_F(PeeringStateTest, RebuildStatsNoDoubleLatch) { + dout(0) << "== RebuildStatsNoDoubleLatch ==" << dendl; + test_create_peering_state(); + test_init(); + test_event_initialize(); + eversion_t v = test_append_log_entry(); + test_peering(); + verify_all_active_clean(v, eversion_t()); + + call_prepare_stats(acting_primary); + + modify_up_acting(1, 9); + test_create_peering_state(9, 1); + test_init(9); + test_event_initialize(9); + test_peering(); + + PerfCounters *perf = get_listener(acting_primary)->recoverystate_perf; + + // First vulnerable call — latch fires (rebuild_start_time set). + call_prepare_stats(acting_primary); + + // Second vulnerable call while still degraded. The latch is guarded by + // rebuild_start_time == utime_t(), which is now false, so the start + // time is not overwritten and no record is emitted. + call_prepare_stats(acting_primary); + + // Nothing recorded yet (PG still degraded): duration avgcount must be 0. + { + auto [sum_ns, count] = perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 0u); + } + + // Now complete the recovery and verify exactly one event is recorded. + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + test_begin_peer_recover(9, 1); + test_on_peer_recover(9, 1, v); + test_recover_got(9, v); + test_object_recovered(); + test_event_all_replicas_recovered(); + verify_all_active_clean(v, eversion_t()); + call_prepare_stats(acting_primary); + + { + auto [sum_ns, count] = perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 1u); + } +} + +// ============================================================================ +// Test 4: Two sequential failure+recovery cycles accumulate independently. +// +// Cycle 1: acting[1] = 9 (OSD 1 -> OSD 9) +// Cycle 2: acting[2] = 8 (OSD 2 -> OSD 8, while OSD 9 stays in slot 1) +// +// Using distinct slots avoids having to bring OSD 9 stale mid-test while +// still exercising two independent latch+record sequences on the same primary. +// ============================================================================ +TEST_F(PeeringStateTest, RebuildStatsCountAccumulates) { + dout(0) << "== RebuildStatsCountAccumulates ==" << dendl; + test_create_peering_state(); + test_init(); + test_event_initialize(); + eversion_t v = test_append_log_entry(); + test_peering(); + verify_all_active_clean(v, eversion_t()); + + PerfCounters *perf = get_listener(acting_primary)->recoverystate_perf; + + // ---- Cycle 1: replace acting[1] with OSD 9 ---- + call_prepare_stats(acting_primary); // stamp last_clean + + modify_up_acting(1, 9); + test_create_peering_state(9, 1); + test_init(9); + test_event_initialize(9); + test_peering(); + // active+recovering: OSD 9 needs recovery. + + call_prepare_stats(acting_primary); // latch fires + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + test_begin_peer_recover(9, 1); + test_on_peer_recover(9, 1, v); + test_recover_got(9, v); + test_object_recovered(); + test_event_all_replicas_recovered(); + verify_all_active_clean(v, eversion_t()); + // Flush share_pg_info messages queued by cycle 1's Clean so they are + // delivered to the current replicas now, not stale during cycle 2. + dispatch_all(); + call_prepare_stats(acting_primary); // record fires + + { + auto [sum_ns, count] = perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 1u); + } + + // ---- Cycle 2: replace acting[2] with OSD 8 ---- + // OSD 9 remains in slot 1; OSD 8 joins as a fresh replica at slot 2. + call_prepare_stats(acting_primary); // stamp last_clean for cycle 2 + + modify_up_acting(2, 8); + test_create_peering_state(8, 2); + test_init(8); + test_event_initialize(8); + test_peering(); + // active+recovering: OSD 8 needs the object recovered to it. + + call_prepare_stats(acting_primary); // second latch fires + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + test_begin_peer_recover(8, 2); + test_on_peer_recover(8, 2, v); + test_recover_got(8, v); + test_object_recovered(); + test_event_all_replicas_recovered(); + verify_all_active_clean(v, eversion_t()); + call_prepare_stats(acting_primary); // second record fires + + // Both events must appear in the duration avgcount. + auto [sum_ns, count] = perf->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 2u); + EXPECT_GT(sum_ns, 0u); + + // min <= max invariant must hold across both events. + EXPECT_LE(perf->get(rs_pg_rebuild_min_secs), perf->get(rs_pg_rebuild_max_secs)); +} + +// ============================================================================ +// Test 5: Latch is discarded when the OSD loses its primary role mid-rebuild. +// +// If a new interval begins while rebuild_start_time is set and the OSD +// transitions from primary to stray, clear_primary_state() must discard the +// stale latch so no spurious rebuild event is emitted for a recovery that the +// OSD no longer owns. +// ============================================================================ +TEST_F(PeeringStateTest, RebuildStatsLatchClearedOnRoleChange) { + dout(0) << "== RebuildStatsLatchClearedOnRoleChange ==" << dendl; + test_create_peering_state(); + test_init(); + test_event_initialize(); + eversion_t v = test_append_log_entry(); + test_peering(); + verify_all_active_clean(v, eversion_t()); + + // Record last_clean so the new_failure guard inside the latch is satisfied. + call_prepare_stats(acting_primary); // acting_primary = OSD 0 + + // --- Phase 1: degrade the PG while OSD 0 is still primary. --- + // Replace acting[1] (OSD 1) with OSD 9; OSD 9 needs recovery. + modify_up_acting(1, 9); + test_create_peering_state(9, 1); + test_init(9); + test_event_initialize(9); + test_peering(); + // PG is now active+recovering+degraded; OSD 0 remains primary. + + PerfCounters *perf_osd0 = get_listener(0)->recoverystate_perf; + + // Latch: first prepare_stats call while vulnerable sets rebuild_start_time + // on OSD 0. + call_prepare_stats(acting_primary); + ASSERT_NE(get_ps(0)->get_rebuild_start_time(), utime_t()) + << "latch must be set before role change"; + + // --- Phase 2: change the primary before recovery completes. --- + // Swap acting[0] from OSD 0 to OSD 7. OSD 0 leaves the acting set + // entirely and becomes a stray; OSD 7 takes over as primary. + modify_up_acting(0, 7); + acting_primary = 7; + up_primary = 7; + test_create_peering_state(7, 0); + test_init(7); + test_event_initialize(7); + + // advance_map on OSD 0: the new acting set excludes OSD 0, so + // should_restart_peering()->start_peering_interval()->clear_primary_state() + // resets the three latch variables. + test_event_advance_map(); + + // --- Phase 3: verify latch is cleared on the old primary. --- + EXPECT_EQ(get_ps(0)->get_rebuild_start_time(), utime_t()); + EXPECT_EQ(get_ps(0)->get_rebuild_base_recovered(), 0); + EXPECT_FALSE(get_ps(0)->get_rebuild_had_redundancy_loss()); + + // No rebuild record must have been emitted: the latch was discarded, not + // fired. A spurious emission here would record a duration spanning a + // recovery that OSD 0 did not complete. + auto [sum_ns, count] = perf_osd0->get_tavg_ns(rs_pg_rebuild_duration); + EXPECT_EQ(count, 0u); + EXPECT_EQ(sum_ns, 0u); +} + // ============================================================================ // Main // ============================================================================ From 70a122f21b07ffefa38984411965655f68c12cd4 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Mon, 1 Jun 2026 17:59:40 +0530 Subject: [PATCH 352/596] mgr/dashboard: fix-user-creation-validation Fixes: https://tracker.ceph.com/issues/77024 Signed-off-by: Sagar Gopale --- .../frontend/cypress/e2e/ui/user-mgmt.po.ts | 9 + .../auth/user-form/user-form.component.html | 328 ++++++++++-------- .../user-form/user-form.component.spec.ts | 39 +++ .../auth/user-form/user-form.component.ts | 25 +- .../src/app/shared/forms/cd-validators.ts | 3 +- 5 files changed, 254 insertions(+), 150 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/user-mgmt.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/user-mgmt.po.ts index 76679806a88..eb79748be1d 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/user-mgmt.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/user-mgmt.po.ts @@ -16,6 +16,15 @@ export class UserMgmtPageHelper extends PageHelper { cy.get('#name').type(name); cy.get('#email').type(email); + // Select role 'administrator' + cy.get('cds-combo-box[type="multi"] input.cds--text-input').first().click({ force: true }); + cy.get('.cds--list-box__menu.cds--multi-select').should('be.visible'); + cy.get('.cds--list-box__menu.cds--multi-select .cds--checkbox-label') + .contains('.cds--checkbox-label-text', 'administrator', { matchCase: false }) + .parent() + .click({ force: true }); + cy.get('body').type('{esc}'); + // Click the create button and wait for user to be made cy.get('[data-testid=submitBtn]').click(); this.getFirstTableCell(username).should('exist'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html index ea8f8aed077..843c08e5b5c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html @@ -1,73 +1,82 @@ -
+
-
-
{{ action | titlecase }} {{ resource | upperFirst }} + +
+ {{ action | titlecase }} {{ resource | upperFirst }}
- Username - + Username + - - This field is required. - + This field is required. - - The username already exists. - + The username already exists.
- Password - - - + Password + + + - Password confirmation doesn't match the password. + Password confirmation doesn't match the password. - This field is required. - + This field is required. + {{ passwordValuation }} @@ -75,142 +84,171 @@
- Confirm password - + + Confirm password + - Password confirmation doesn't match the password. - This field is required. + Password confirmation doesn't match the password. + This field is required.
-
- {{'Password Expiration Date'}} - +
+ {{ 'Password Expiration Date' }} + - The Dashboard setting defining the expiration interval of - passwords is currently set to 0. This means - if a date is set, the user password will only expire once. + The Dashboard setting defining the expiration interval of passwords is currently set + to 0. This means if a date is set, the user password will only expire + once. - Consider configuring the Dashboard setting - USER_PWD_EXPIRATION_SPAN - in order to let passwords expire periodically. + Consider configuring the Dashboard setting + USER_PWD_EXPIRATION_SPAN + in order to let passwords expire periodically. - - - - This field is required. + + + + This field is required.
- Full Name - + + Full Name +
- + Email - + - Invalid email. + Invalid email.
-
- +
+ + + This field is required. +
-
- Enabled +
+ Enabled
-
- User must change password at next login +
+ User must change password at next login
- +
-

You are about to remove "user read / update" permissions from your own user.

-
+

+ You are about to remove "user read / update" permissions from your own user. +

+

If you continue, you will no longer be able to add or remove roles from any user.

Are you sure you want to continue?
- + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts index 13034934486..850dff1c5a7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts @@ -62,12 +62,16 @@ describe('UserFormComponent', () => { form = component.userForm; httpTesting = TestBed.inject(HttpTestingController); userService = TestBed.inject(UserService); + spyOn(userService, 'validatePassword').and.returnValue( + of({ valid: true, credits: 10, valuation: 'strong' }) + ); modalService = TestBed.inject(ModalCdsService); router = TestBed.inject(Router); spyOn(router, 'navigate'); fixture.detectChanges(); const notify = TestBed.inject(NotificationService); spyOn(notify, 'show'); + spyOn(TestBed.inject(AuthStorageService), 'isSSO').and.returnValue(false); formHelper = new FormHelper(form); }); @@ -109,6 +113,34 @@ describe('UserFormComponent', () => { formHelper.expectErrorChange('email', 'aaa', 'email'); }); + it('should validate password required in create mode', () => { + formHelper.expectErrorChange('password', '', 'required'); + formHelper.expectValidChange('password', 'pass123'); + }); + + it('should validate confirmpassword required in create mode', () => { + formHelper.setValue('password', 'pass123'); + formHelper.expectErrorChange('confirmpassword', '', 'required'); + formHelper.expectValidChange('confirmpassword', 'pass123'); + }); + + it('should validate roles required in create mode', () => { + formHelper.expectErrorChange('roles', [], 'required'); + formHelper.expectValidChange('roles', ['administrator']); + }); + + it('should not validate password and roles if SSO is enabled', () => { + component.isSSO = true; + component.createForm(); + form = component.userForm; + form.get('password').updateValueAndValidity(); + expect(form.get('password').valid).toBeTruthy(); + form.get('confirmpassword').updateValueAndValidity(); + expect(form.get('confirmpassword').valid).toBeTruthy(); + form.get('roles').updateValueAndValidity(); + expect(form.get('roles').valid).toBeTruthy(); + }); + it('should set mode', () => { expect(component.mode).toBeUndefined(); }); @@ -210,6 +242,13 @@ describe('UserFormComponent', () => { expect(component.mode).toBe('editing'); }); + it('should not validate password required in edit mode', () => { + form.get('password').setValue(''); + expect(form.get('password').valid).toBeTruthy(); + form.get('confirmpassword').setValue(''); + expect(form.get('confirmpassword').valid).toBeTruthy(); + }); + it('should alert if user is removing needed role permission', () => { spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username); let modalBodyTpl = null; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts index c67dbad95d3..6ee9b986525 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts @@ -58,6 +58,7 @@ export class UserFormComponent extends CdForm implements OnInit { pwdExpirationFormat = 'YYYY-MM-DD'; selectedRole: string[]; passwordexp: boolean = false; + isSSO = false; constructor( private authService: AuthService, private authStorageService: AuthStorageService, @@ -74,6 +75,7 @@ export class UserFormComponent extends CdForm implements OnInit { ) { super(); this.resource = $localize`user`; + this.isSSO = this.authStorageService.isSSO(); this.createForm(); this.messages = new SelectMessages({ empty: $localize`There are no roles.` }); } @@ -92,8 +94,12 @@ export class UserFormComponent extends CdForm implements OnInit { name: [''], password: [ '', - [], - + [ + (control) => + this.mode === this.userFormMode.editing || this.isSSO + ? null + : Validators.required(control) + ], [ CdValidators.passwordPolicy( this.userService, @@ -107,10 +113,18 @@ export class UserFormComponent extends CdForm implements OnInit { ) ] ], - confirmpassword: [''], + confirmpassword: [ + '', + [ + (control) => + this.mode === this.userFormMode.editing || this.isSSO + ? null + : Validators.required(control) + ] + ], pwdExpirationDate: [''], email: ['', [CdValidators.email]], - roles: [[]], + roles: [[], [(control) => (this.isSSO ? null : Validators.required(control))]], enabled: [true, [Validators.required]], pwdUpdateRequired: [true] }, @@ -129,6 +143,8 @@ export class UserFormComponent extends CdForm implements OnInit { this.action = this.actionLabels.CREATE; this.passwordexp = true; } + this.userForm.get('password').updateValueAndValidity(); + this.userForm.get('confirmpassword').updateValueAndValidity(); const observables = [this.roleService.list(), this.settingsService.getStandardSettings()]; observableForkJoin(observables).subscribe( @@ -149,6 +165,7 @@ export class UserFormComponent extends CdForm implements OnInit { expirationDate.add(this.pwdExpirationSettings.pwdExpirationSpan, 'day'); pwdExpirationDateField.setValue(expirationDate.format(this.pwdExpirationFormat)); pwdExpirationDateField.setValidators([Validators.required]); + pwdExpirationDateField.updateValueAndValidity(); } this.loadingReady(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 997564a983c..288151c24fa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -349,7 +349,8 @@ export class CdValidators { return null; } if (ctrl1.value !== ctrl2.value) { - ctrl2.setErrors({ match: true }); + const errors = _.merge({}, ctrl2.errors, { match: true }); + ctrl2.setErrors(errors); } else { const hasError = ctrl2.hasError('match'); if (hasError) { From 617f55391339c1c94259de63a6e7886fcb11ca31 Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Tue, 9 Jun 2026 14:04:53 -0400 Subject: [PATCH 353/596] qa/dnsmasq: use managed dnsmasq instead of editing resolv.conf this task was manually editing /etc/resolv.conf to configure dns names. on ubuntu 24, these changes were getting clobbered by NetworkManager or systemd service restarts instead, manage dnsmasq depending on host distro. rpm-based distros use NetworkManager's dnsmasq plugin, while deb-based distros use systemd-resolved to point at the local dnsmasq Fixes: https://tracker.ceph.com/issues/76961 Signed-off-by: Casey Bodley --- qa/tasks/dnsmasq.py | 117 +++++++++++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 44 deletions(-) diff --git a/qa/tasks/dnsmasq.py b/qa/tasks/dnsmasq.py index ea6b0bbf5ac..13e44bbfe1f 100644 --- a/qa/tasks/dnsmasq.py +++ b/qa/tasks/dnsmasq.py @@ -66,47 +66,75 @@ def replace_resolv(remote, path, table): remote.run(args=['rm', path]) @contextlib.contextmanager -def setup_dnsmasq(remote, testdir, cnames, table): - """ configure dnsmasq on the given remote, adding each cname given """ - log.info('Configuring dnsmasq on remote %s..', remote.name) - # banner first - dnsmasq = ["# This file is generated by dnsmasq teuthology task", ""] - - # lookup previously used nameservers for the remote in the table - servers = table.get(remote.name, {}).get('nameserver', None) - if servers: - log.info('Reusing nameservers: %s', servers) - dnsmasq.extend(f"server={a}" for a in servers) - else: - log.info('No predefined nameservers found, using 8.8.x.x') - dnsmasq.extend(["server=8.8.8.8", "server=8.8.4.4"]) - +def configure_dnsmasq(remote, path, cnames): + """ + configure dnsmasq on the given remote, adding each cname given + """ + data = """# This file is generated by dnsmasq teuthology task +""" for cname, ip_address in cnames.items(): - dnsmasq.append(f"address=/{cname}/{ip_address}") - - dnsmasq.append('') - dnsmasq_data = "\n".join(dnsmasq) - dnsmasq_path = '/etc/dnsmasq.d/ceph' - remote.sudo_write_file(dnsmasq_path, dnsmasq_data) - # restore selinux context if necessary - remote.run(args=['sudo', 'restorecon', dnsmasq_path], check_status=False) - - # restart dnsmasq - remote.run(args=['sudo', 'systemctl', 'restart', 'dnsmasq']) - # some pause is required for dnsmasq to become operational, - # though, trying to just print out the config now instead - remote.sh(f'cat {dnsmasq_path}') - # verify dns name is set - remote.run(args=['ping', '-c', '4', next(iter(cnames.keys()))]) + data += f"address=/.{cname}/{ip_address}\n" + remote.sudo_write_file(path, data) + remote.run(args=['sudo', 'restorecon', path], check_status=False) + remote.sh(f'cat {path}') try: yield finally: - log.info('Removing dnsmasq configuration from remote %s..', remote.name) - # remove /etc/dnsmasq.d/ceph - remote.run(args=['sudo', 'rm', dnsmasq_path]) - # restart dnsmasq - remote.run(args=['sudo', 'systemctl', 'restart', 'dnsmasq']) + remote.run(args=['sudo', 'rm', path]) + +@contextlib.contextmanager +def configure_network_manager(remote): + """ + enable NetworkManager's dnsmasq plugin + """ + path = '/etc/NetworkManager/conf.d/dnsmasq.conf' + data = """# This file is generated by dnsmasq teuthology task +[main] +dns=dnsmasq +""" + remote.sudo_write_file(path, data) + remote.run(args=['sudo', 'restorecon', path], check_status=False) + remote.run(args=['sudo', 'systemctl', 'restart', 'NetworkManager']) + remote.sh(f'cat {path}') + try: + yield + finally: + remote.run(args=['sudo', 'rm', path]) + remote.run(args=['sudo', 'systemctl', 'restart', 'NetworkManager']) + +@contextlib.contextmanager +def configure_systemd_resolved(remote, cnames): + """ + configure systemd-resolved to search for listed cnames through dnsmasq at 127.0.0.1 + """ + dir = '/etc/systemd/resolved.conf.d' + remote.sh(f'sudo mkdir {dir}') + + path = dir + '/dnsmasq.conf' + domains = ' '.join([f'~{cname}' for cname in cnames.keys()]) + data = f"""# This file is generated by dnsmasq teuthology task +[Resolve] +DNS=127.0.0.1 +Domains={domains} +""" + remote.sudo_write_file(path, data) + remote.run(args=['sudo', 'restorecon', path], check_status=False) + remote.run(args=['sudo', 'systemctl', 'restart', 'dnsmasq']) + remote.run(args=['sudo', 'systemctl', 'restart', 'systemd-resolved']) + remote.sh(f'cat {path}') + try: + yield + finally: + remote.run(args=['sudo', 'rm', path]) + remote.run(args=['sudo', 'systemctl', 'restart', 'systemd-resolved']) + +@contextlib.contextmanager +def test_dns_names(remote, cnames): + """ verify that ping can resolve the configured names """ + for cname in cnames.keys(): + remote.run(args=['ping', '-c', '4', cname]) + yield @contextlib.contextmanager def task(ctx, config): @@ -168,18 +196,19 @@ def task(ctx, config): remote_names[remote] = names - testdir = misc.get_testdir(ctx) - resolv_bak = '/'.join((testdir, 'resolv.bak')) - resolv_tmp = '/'.join((testdir, 'resolv.tmp')) - resolv_tbl = dict() # { remote.name -> nameserver list } lookup table - # run subtasks for each unique remote subtasks = [] for remote, cnames in remote_names.items(): subtasks.extend([ lambda r=remote: install_dnsmasq(r) ]) - subtasks.extend([ lambda r=remote: backup_resolv(r, resolv_bak, resolv_tbl) ]) - subtasks.extend([ lambda r=remote: replace_resolv(r, resolv_tmp, resolv_tbl) ]) - subtasks.extend([ lambda r=remote, cn=cnames: setup_dnsmasq(r, testdir, cn, resolv_tbl) ]) + if remote.os.package_type == 'rpm': + path = '/etc/NetworkManager/dnsmasq.d/ceph.conf' + subtasks.extend([ lambda r=remote, p=path, cn=cnames: configure_dnsmasq(r, p, cn) ]) + subtasks.extend([ lambda r=remote: configure_network_manager(r) ]) + else: + path = '/etc/dnsmasq.d/ceph.conf' + subtasks.extend([ lambda r=remote, p=path, cn=cnames: configure_dnsmasq(r, p, cn) ]) + subtasks.extend([ lambda r=remote, cn=cnames: configure_systemd_resolved(r, cn) ]) + subtasks.extend([ lambda r=remote, cn=cnames: test_dns_names(r, cn) ]) with contextutil.nested(*subtasks): yield From 591ce1234b4fa646a0c2edadea5a6a245d2e4c44 Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Tue, 9 Jun 2026 14:13:23 -0400 Subject: [PATCH 354/596] qa/dnsmasq: remove unused backup/replace_resolv() Signed-off-by: Casey Bodley --- qa/tasks/dnsmasq.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/qa/tasks/dnsmasq.py b/qa/tasks/dnsmasq.py index 13e44bbfe1f..4e44c8aa74d 100644 --- a/qa/tasks/dnsmasq.py +++ b/qa/tasks/dnsmasq.py @@ -3,7 +3,6 @@ Task for dnsmasq configuration """ import contextlib import logging -import re from teuthology import misc from teuthology.exceptions import ConfigError @@ -31,40 +30,6 @@ def install_dnsmasq(remote): if existing is None: packaging.remove_package('dnsmasq', remote) -@contextlib.contextmanager -def backup_resolv(remote, path, table): - """ - Store a backup of resolv.conf in the testdir and restore it after the task. - Reserve 'nameserver' values per remote name in lookup 'table' for future use. - """ - resolv_conf = remote.sh(f"cat /etc/resolv.conf | tee {path}") - nameserver_re = re.compile(r'\s*nameserver\s+([^\s]*)') - for line in resolv_conf.split('\n'): - if m := nameserver_re.match(line): - table.setdefault(remote.name, {}).setdefault('nameserver', []).append(m[1]) - try: - yield - finally: - # restore with 'cp' to avoid overwriting its security context - remote.run(args=['sudo', 'cp', path, '/etc/resolv.conf']) - remote.run(args=['rm', path]) - -@contextlib.contextmanager -def replace_resolv(remote, path, table): - """ - Update resolv.conf to point the nameserver at localhost. - """ - remote.write_file(path, "nameserver 127.0.0.1\n") - try: - # install it - if remote.os.package_type == "rpm": - # for centos ovh resolv.conf has immutable attribute set - remote.run(args=['sudo', 'chattr', '-i', '/etc/resolv.conf'], check_status=False) - remote.run(args=['sudo', 'cp', path, '/etc/resolv.conf']) - yield - finally: - remote.run(args=['rm', path]) - @contextlib.contextmanager def configure_dnsmasq(remote, path, cnames): """ From af88cf4531ef5f83a7a4f588813d5d28ea47aeb6 Mon Sep 17 00:00:00 2001 From: Laura Flores Date: Mon, 22 Jun 2026 10:18:50 -0500 Subject: [PATCH 355/596] doc: update email address for Laura Flores Changed Red Hat email to IBM. Signed-off-by: Laura Flores --- doc/governance.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/governance.rst b/doc/governance.rst index 0675d8fcd85..97df9d767d2 100644 --- a/doc/governance.rst +++ b/doc/governance.rst @@ -99,7 +99,7 @@ Current Members * Joseph Mundackal * Josh Durgin * João Eduardo Luis - * Laura Flores + * Laura Flores * Mark Nelson * Matan Breizman * Matt Benjamin From 1651ec9a0f65d566b7677b539e3802fdeee15496 Mon Sep 17 00:00:00 2001 From: Yonatan Zaken Date: Mon, 22 Jun 2026 20:02:39 +0300 Subject: [PATCH 356/596] ceph.in: reject -w/--watch flags when used with subcommands parse_known_args() silently consumes -w and all --watch-* flags before subcommand arguments reach the command validator. When a user ran "ceph orch ps -w", the watch handler entered cluster log streaming mode while discarding the subcommand entirely, with no error or indication that the orch ps command was ignored. Add a guard at the entry of the watch handler: if any watch flag is active and childargs is non-empty, print "Invalid command: unused arguments: [...]" and return EINVAL. This matches the behaviour already seen when -s/--status is combined with a subcommand. Assisted-by: Claude:claude-4.6-sonnet Fixes: https://tracker.ceph.com/issues/77121 Signed-off-by: Yonatan Zaken --- src/ceph.in | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ceph.in b/src/ceph.in index 4694da29db5..3379de7488e 100755 --- a/src/ceph.in +++ b/src/ceph.in @@ -1131,6 +1131,9 @@ def main(): elif k != "watch_channel": level = k.replace('watch_', '') if level: + if childargs: + print('Invalid command: unused arguments: {0}'.format(childargs), file=sys.stderr) + return errno.EINVAL # an awfully simple callback def watch_cb(arg, line, channel, name, who, stamp_sec, stamp_nsec, seq, level, msg): # Filter on channel From 868d20182a08685265e0b5831bad703fdcf3189b Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Thu, 11 Jun 2026 18:46:45 -0400 Subject: [PATCH 357/596] test/rgw: Include s3-tests in Ceph repo Signed-off-by: Adam C. Emerson --- src/test/rgw/s3-tests/LICENSE | 19 + src/test/rgw/s3-tests/README.rst | 120 + src/test/rgw/s3-tests/pytest.ini | 60 + src/test/rgw/s3-tests/requirements.txt | 13 + src/test/rgw/s3-tests/s3tests.conf.SAMPLE | 181 + src/test/rgw/s3-tests/s3tests/__init__.py | 0 src/test/rgw/s3-tests/s3tests/common.py | 301 + .../s3-tests/s3tests/functional/__init__.py | 883 + .../rgw/s3-tests/s3tests/functional/iam.py | 199 + .../rgw/s3-tests/s3tests/functional/policy.py | 46 + .../s3tests/functional/rgw_interactive.py | 92 + .../s3tests/functional/test_headers.py | 578 + .../s3-tests/s3tests/functional/test_iam.py | 3007 +++ .../s3-tests/s3tests/functional/test_s3.py | 20779 ++++++++++++++++ .../s3tests/functional/test_s3control.py | 90 + .../s3tests/functional/test_s3select.py | 1685 ++ .../s3-tests/s3tests/functional/test_sns.py | 186 + .../s3-tests/s3tests/functional/test_sts.py | 2073 ++ .../s3-tests/s3tests/functional/test_utils.py | 9 + .../rgw/s3-tests/s3tests/functional/utils.py | 47 + src/test/rgw/s3-tests/setup.py | 22 + src/test/rgw/s3-tests/tox.ini | 9 + 22 files changed, 30399 insertions(+) create mode 100644 src/test/rgw/s3-tests/LICENSE create mode 100644 src/test/rgw/s3-tests/README.rst create mode 100644 src/test/rgw/s3-tests/pytest.ini create mode 100644 src/test/rgw/s3-tests/requirements.txt create mode 100644 src/test/rgw/s3-tests/s3tests.conf.SAMPLE create mode 100644 src/test/rgw/s3-tests/s3tests/__init__.py create mode 100644 src/test/rgw/s3-tests/s3tests/common.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/__init__.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/iam.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/policy.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/rgw_interactive.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_headers.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_iam.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_s3.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_s3control.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_s3select.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_sns.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_sts.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/test_utils.py create mode 100644 src/test/rgw/s3-tests/s3tests/functional/utils.py create mode 100644 src/test/rgw/s3-tests/setup.py create mode 100644 src/test/rgw/s3-tests/tox.ini diff --git a/src/test/rgw/s3-tests/LICENSE b/src/test/rgw/s3-tests/LICENSE new file mode 100644 index 00000000000..10996d2f3a1 --- /dev/null +++ b/src/test/rgw/s3-tests/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 New Dream Network, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/test/rgw/s3-tests/README.rst b/src/test/rgw/s3-tests/README.rst new file mode 100644 index 00000000000..0713aeeae99 --- /dev/null +++ b/src/test/rgw/s3-tests/README.rst @@ -0,0 +1,120 @@ +======================== + S3 compatibility tests +======================== + +This is a set of unofficial Amazon AWS S3 compatibility +tests, that can be useful to people implementing software +that exposes an S3-like API. The tests use the Boto2 and Boto3 libraries. + +The tests use the Tox tool. To get started, ensure you have the ``tox`` +software installed; e.g. on Debian/Ubuntu:: + + sudo apt-get install tox + +You will need to create a configuration file with the location of the +service and two different credentials. A sample configuration file named +``s3tests.conf.SAMPLE`` has been provided in this repo. This file can be +used to run the s3 tests on a Ceph cluster started with vstart. + +Once you have that file copied and edited, you can run the tests with:: + + S3TEST_CONF=your.conf tox + +You can specify which directory of tests to run:: + + S3TEST_CONF=your.conf tox -- s3tests/functional + +You can specify which file of tests to run:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_s3.py + +You can specify which test to run:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_s3.py::test_bucket_list_empty + +Some tests have attributes set based on their current reliability and +things like AWS not enforcing their spec stricly. You can filter tests +based on their attributes:: + + S3TEST_CONF=aws.conf tox -- -m 'not fails_on_aws' + +Most of the tests have both Boto3 and Boto2 versions. Tests written in +Boto2 are in the ``s3tests`` directory. Tests written in Boto3 are +located in the ``s3test_boto3`` directory. + +You can run only the boto3 tests with:: + + S3TEST_CONF=your.conf tox -- s3tests/functional + +======================== + STS compatibility tests +======================== + +This section contains some basic tests for the AssumeRole, GetSessionToken and AssumeRoleWithWebIdentity API's. The test file is located under ``s3tests/functional``. + +To run the STS tests, the vstart cluster should be started with the following parameter (in addition to any parameters already used with it):: + + vstart.sh -o rgw_sts_key=abcdefghijklmnop -o rgw_s3_auth_use_sts=true + +Note that the ``rgw_sts_key`` can be set to anything that is 128 bits in length. +After the cluster is up the following command should be executed:: + + radosgw-admin caps add --tenant=testx --uid="9876543210abcdef0123456789abcdef0123456789abcdef0123456789abcdef" --caps="roles=*" + +You can run only the sts tests (all the three API's) with:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_sts.py + +You can filter tests based on the attributes. There is a attribute named ``test_of_sts`` to run AssumeRole and GetSessionToken tests and ``webidentity_test`` to run the AssumeRoleWithWebIdentity tests. If you want to execute only ``test_of_sts`` tests you can apply that filter as below:: + + S3TEST_CONF=your.conf tox -- -m test_of_sts s3tests/functional/test_sts.py + +For running ``webidentity_test`` you'll need have Keycloak running. + +In order to run any STS test you'll need to add "iam" section to the config file. For further reference on how your config file should look check ``s3tests.conf.SAMPLE``. + +======================== + IAM policy tests +======================== + +This is a set of IAM policy tests. +This section covers tests for user policies such as Put, Get, List, Delete, user policies with s3 actions, conflicting user policies etc +These tests uses Boto3 libraries. Tests are written in the ``s3test_boto3`` directory. + +These iam policy tests uses two users with profile name "iam" and "s3 alt" as mentioned in s3tests.conf.SAMPLE. +If Ceph cluster is started with vstart, then above two users will get created as part of vstart with same access key, secrete key etc as mentioned in s3tests.conf.SAMPLE. +Out of those two users, "iam" user is with capabilities --caps=user-policy=* and "s3 alt" user is without capabilities. +Adding above capabilities to "iam" user is also taken care by vstart (If Ceph cluster is started with vstart). + +To run these tests, create configuration file with section "iam" and "s3 alt" refer s3tests.conf.SAMPLE. +Once you have that configuration file copied and edited, you can run all the tests with:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_iam.py + +You can also specify specific test to run:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_iam.py::test_put_user_policy + +Some tests have attributes set such as "fails_on_rgw". +You can filter tests based on their attributes:: + + S3TEST_CONF=your.conf tox -- s3tests/functional/test_iam.py -m 'not fails_on_rgw' + +======================== + Bucket logging tests +======================== + +Ceph has extensions for the bucket logging S3 API. For the tests to cover these extensions, the following file: `examples/rgw/boto3/service-2.sdk-extras.json` from the Ceph repo, +should be copied to the: `~/.aws/models/s3/2006-03-01/` directory on the machine where the tests are run. +If the file is not present, the tests will still run, but the extension tests will be skipped. In this case, the bucket logging object roll time must be decreased manually from its default of +300 seconds to 5 seconds:: + + vstart.sh -o rgw_bucket_logging_object_roll_time=5 + +Then the tests can be run with:: + + S3TEST_CONF=your.conf tox -- -m 'bucket_logging' + +To run the only bucket logging tests that do not need extension of rollover time, use:: + + S3TEST_CONF=your.conf tox -- -m 'bucket_logging and not fails_without_logging_rollover' diff --git a/src/test/rgw/s3-tests/pytest.ini b/src/test/rgw/s3-tests/pytest.ini new file mode 100644 index 00000000000..b3c3465e8b8 --- /dev/null +++ b/src/test/rgw/s3-tests/pytest.ini @@ -0,0 +1,60 @@ +[pytest] +markers = + abac_test + appendobject + auth_aws2 + auth_aws4 + auth_common + bucket_policy + bucket_encryption + bucket_logging + bucket_logging_cleanup + conditional_write + fails_without_logging_rollover + checksum + cloud_transition + cloud_restore + target_by_bucket + encryption + fails_on_aws + fails_on_dbstore + fails_on_dho + fails_on_mod_proxy_fcgi + fails_on_rgw + fails_on_s3 + fails_with_subdomain + group + group_policy + iam_account + iam_cross_account + iam_role + iam_tenant + iam_user + lifecycle + lifecycle_expiration + lifecycle_transition + list_objects_v2 + object_lock + object_ownership + role_policy + session_policy + s3control + s3select + s3website + s3website_routing_rules + s3website_redirect_location + sns + sse_s3 + storage_class + tagging + test_of_sts + token_claims_trust_policy_test + token_principal_tag_role_policy_test + token_request_tag_trust_policy_test + token_resource_tags_test + token_role_tags_test + token_tag_keys_test + user_policy + versioning + webidentity_test + delete_marker diff --git a/src/test/rgw/s3-tests/requirements.txt b/src/test/rgw/s3-tests/requirements.txt new file mode 100644 index 00000000000..0b8e452f21e --- /dev/null +++ b/src/test/rgw/s3-tests/requirements.txt @@ -0,0 +1,13 @@ +PyYAML +boto3 >=1.0.0 +botocore +munch >=2.0.0 +# 0.14 switches to libev, that means bootstrap needs to change too +gevent >=1.0 +isodate >=0.4.4 +requests >=2.23.0 +pytz +httplib2 +lxml +pytest +tox diff --git a/src/test/rgw/s3-tests/s3tests.conf.SAMPLE b/src/test/rgw/s3-tests/s3tests.conf.SAMPLE new file mode 100644 index 00000000000..5a4c4b4f625 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests.conf.SAMPLE @@ -0,0 +1,181 @@ +[DEFAULT] +## this section is just used for host, port and bucket_prefix + +# host set for rgw in vstart.sh +host = localhost + +# port set for rgw in vstart.sh +port = 8000 + +## say "False" to disable TLS +is_secure = False + +## say "False" to disable SSL Verify +ssl_verify = False + +[fixtures] +## all the buckets created will start with this prefix; +## {random} will be filled with random characters to pad +## the prefix to 30 characters long, and avoid collisions +bucket prefix = yournamehere-{random}- + +# all the iam account resources (users, roles, etc) created +# will start with this name prefix +iam name prefix = s3-tests- + +# all the iam account resources (users, roles, etc) created +# will start with this path prefix +iam path prefix = /s3-tests/ + +[s3 main] +# main display_name set in vstart.sh +display_name = M. Tester + +# main user_idname set in vstart.sh +user_id = testid + +# main email set in vstart.sh +email = tester@ceph.com + +# zonegroup api_name for bucket location +api_name = default + +## main AWS access key +access_key = 0555b35654ad1656d804 + +## main AWS secret key +secret_key = h7GhxuBLTrlhVUyxSPUKUV8r/2EI4ngqJxD7iBdBYLhwluN30JaT3Q== + +## replace with key id obtained when secret is created, or delete if KMS not tested +#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef + +## Storage classes +#storage_classes = "LUKEWARM, FROZEN" + +## Lifecycle debug interval (default: 10) +#lc_debug_interval = 20 +## Restore debug interval (default: 100) +#rgw_restore_debug_interval = 60 +#rgw_restore_processor_period = 60 + +[s3 alt] +# alt display_name set in vstart.sh +display_name = john.doe +## alt email set in vstart.sh +email = john.doe@example.com + +# alt user_id set in vstart.sh +user_id = 56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234 + +# alt AWS access key set in vstart.sh +access_key = NOPQRSTUVWXYZABCDEFG + +# alt AWS secret key set in vstart.sh +secret_key = nopqrstuvwxyzabcdefghijklmnabcdefghijklm + +#[s3 cloud] +## to run the testcases with "cloud_transition" for transition +## and "cloud_restore" for restore attribute. +## Note: the waiting time may have to tweaked depending on +## the I/O latency to the cloud endpoint. + +## host set for cloud endpoint +# host = localhost + +## port set for cloud endpoint +# port = 8001 + +## say "False" to disable TLS +# is_secure = False + +## cloud endpoint credentials +# access_key = 0555b35654ad1656d804 +# secret_key = h7GhxuBLTrlhVUyxSPUKUV8r/2EI4ngqJxD7iBdBYLhwluN30JaT3Q== + +## storage class configured as cloud tier on local rgw server +# cloud_storage_class = CLOUDTIER + +## Below are optional - + +## Above configured cloud storage class config options +# retain_head_object = false +# allow_read_through = false # change it to enable read_through +# read_through_restore_days = 2 +# target_storage_class = Target_SC +# target_path = cloud-bucket +# target_by_bucket = false # when true, each source bucket gets its own target bucket +# target_by_bucket_prefix = rgwx-${zonegroup}-${storage_class}-${bucket} # template for per-bucket target names + +## another regular storage class to test multiple transition rules, +# storage_class = S1 + +[s3 tenant] +# tenant display_name set in vstart.sh +display_name = testx$tenanteduser + +# tenant user_id set in vstart.sh +user_id = testx$9876543210abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +# tenant AWS secret key set in vstart.sh +access_key = HIJKLMNOPQRSTUVWXYZA + +# tenant AWS secret key set in vstart.sh +secret_key = opqrstuvwxyzabcdefghijklmnopqrstuvwxyzab + +# tenant email set in vstart.sh +email = tenanteduser@example.com + +# tenant name +tenant = testx + +#following section needs to be added for all sts-tests +[iam] +#used for iam operations in sts-tests +#email from vstart.sh +email = s3@example.com + +#user_id from vstart.sh +user_id = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + +#access_key from vstart.sh +access_key = ABCDEFGHIJKLMNOPQRST + +#secret_key vstart.sh +secret_key = abcdefghijklmnopqrstuvwxyzabcdefghijklmn + +#display_name from vstart.sh +display_name = youruseridhere + +# iam account root user for iam_account tests +[iam root] +access_key = AAAAAAAAAAAAAAAAAAaa +secret_key = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +account_id = RGW11111111111111111 +user_id = RGW11111111111111111 +email = account1@ceph.com + +# iam account root user in a different account than [iam root] +[iam alt root] +access_key = BBBBBBBBBBBBBBBBBBbb +secret_key = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +account_id = RGW22222222222222222 +user_id = RGW22222222222222222 +email = account2@ceph.com + +#following section needs to be added when you want to run Assume Role With Webidentity test +[webidentity] +#used for assume role with web identity test in sts-tests +#all parameters will be obtained from ceph/qa/tasks/keycloak.py +token= + +aud= + +sub= + +azp= + +user_token=] + +thumbprint= + +KC_REALM= diff --git a/src/test/rgw/s3-tests/s3tests/__init__.py b/src/test/rgw/s3-tests/s3tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/test/rgw/s3-tests/s3tests/common.py b/src/test/rgw/s3-tests/s3tests/common.py new file mode 100644 index 00000000000..987ec6b631b --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/common.py @@ -0,0 +1,301 @@ +import boto.s3.connection +import munch +import itertools +import os +import random +import string +import yaml +import re +from lxml import etree + +from doctest import Example +from lxml.doctestcompare import LXMLOutputChecker + +s3 = munch.Munch() +config = munch.Munch() +prefix = '' + +bucket_counter = itertools.count(1) +key_counter = itertools.count(1) + +def choose_bucket_prefix(template, max_len=30): + """ + Choose a prefix for our test buckets, so they're easy to identify. + + Use template and feed it more and more random filler, until it's + as long as possible but still below max_len. + """ + rand = ''.join( + random.choice(string.ascii_lowercase + string.digits) + for c in range(255) + ) + + while rand: + s = template.format(random=rand) + if len(s) <= max_len: + return s + rand = rand[:-1] + + raise RuntimeError( + 'Bucket prefix template is impossible to fulfill: {template!r}'.format( + template=template, + ), + ) + +def nuke_bucket(bucket): + try: + bucket.set_canned_acl('private') + # TODO: deleted_cnt and the while loop is a work around for rgw + # not sending the + deleted_cnt = 1 + while deleted_cnt: + deleted_cnt = 0 + for key in bucket.list(): + print('Cleaning bucket {bucket} key {key}'.format( + bucket=bucket, + key=key, + )) + key.set_canned_acl('private') + key.delete() + deleted_cnt += 1 + bucket.delete() + except boto.exception.S3ResponseError as e: + # TODO workaround for buggy rgw that fails to send + # error_code, remove + if (e.status == 403 + and e.error_code is None + and e.body == ''): + e.error_code = 'AccessDenied' + if e.error_code != 'AccessDenied': + print('GOT UNWANTED ERROR', e.error_code) + raise + # seems like we're not the owner of the bucket; ignore + pass + +def nuke_prefixed_buckets(): + for name, conn in list(s3.items()): + print('Cleaning buckets from connection {name}'.format(name=name)) + for bucket in conn.get_all_buckets(): + if bucket.name.startswith(prefix): + print('Cleaning bucket {bucket}'.format(bucket=bucket)) + nuke_bucket(bucket) + + print('Done with cleanup of test buckets.') + +def read_config(fp): + config = munch.Munch() + g = yaml.safe_load_all(fp) + for new in g: + config.update(munch.Munchify(new)) + return config + +def connect(conf): + mapping = dict( + port='port', + host='host', + is_secure='is_secure', + access_key='aws_access_key_id', + secret_key='aws_secret_access_key', + ) + kwargs = dict((mapping[k],v) for (k,v) in conf.items() if k in mapping) + #process calling_format argument + calling_formats = dict( + ordinary=boto.s3.connection.OrdinaryCallingFormat(), + subdomain=boto.s3.connection.SubdomainCallingFormat(), + vhost=boto.s3.connection.VHostCallingFormat(), + ) + kwargs['calling_format'] = calling_formats['ordinary'] + if 'calling_format' in conf: + raw_calling_format = conf['calling_format'] + try: + kwargs['calling_format'] = calling_formats[raw_calling_format] + except KeyError: + raise RuntimeError( + 'calling_format unknown: %r' % raw_calling_format + ) + # TODO test vhost calling format + conn = boto.s3.connection.S3Connection(**kwargs) + return conn + +def setup(): + global s3, config, prefix + s3.clear() + config.clear() + + try: + path = os.environ['S3TEST_CONF'] + except KeyError: + raise RuntimeError( + 'To run tests, point environment ' + + 'variable S3TEST_CONF to a config file.', + ) + with file(path) as f: + config.update(read_config(f)) + + # These 3 should always be present. + if 's3' not in config: + raise RuntimeError('Your config file is missing the s3 section!') + if 'defaults' not in config.s3: + raise RuntimeError('Your config file is missing the s3.defaults section!') + if 'fixtures' not in config: + raise RuntimeError('Your config file is missing the fixtures section!') + + template = config.fixtures.get('bucket prefix', 'test-{random}-') + prefix = choose_bucket_prefix(template=template) + if prefix == '': + raise RuntimeError("Empty Prefix! Aborting!") + + defaults = config.s3.defaults + for section in list(config.s3.keys()): + if section == 'defaults': + continue + + conf = {} + conf.update(defaults) + conf.update(config.s3[section]) + conn = connect(conf) + s3[section] = conn + + # WARNING! we actively delete all buckets we see with the prefix + # we've chosen! Choose your prefix with care, and don't reuse + # credentials! + + # We also assume nobody else is going to use buckets with that + # prefix. This is racy but given enough randomness, should not + # really fail. + nuke_prefixed_buckets() + +def get_new_bucket(connection=None): + """ + Get a bucket that exists and is empty. + + Always recreates a bucket from scratch. This is useful to also + reset ACLs and such. + """ + if connection is None: + connection = s3.main + name = '{prefix}{num}'.format( + prefix=prefix, + num=next(bucket_counter), + ) + # the only way for this to fail with a pre-existing bucket is if + # someone raced us between setup nuke_prefixed_buckets and here; + # ignore that as astronomically unlikely + bucket = connection.create_bucket(name) + return bucket + +def teardown(): + nuke_prefixed_buckets() + +def with_setup_kwargs(setup, teardown=None): + """Decorator to add setup and/or teardown methods to a test function:: + + @with_setup_args(setup, teardown) + def test_something(): + " ... " + + The setup function should return (kwargs) which will be passed to + test function, and teardown function. + + Note that `with_setup_kwargs` is useful *only* for test functions, not for test + methods or inside of TestCase subclasses. + """ + def decorate(func): + kwargs = {} + + def test_wrapped(*args, **kwargs2): + k2 = kwargs.copy() + k2.update(kwargs2) + k2['testname'] = func.__name__ + func(*args, **k2) + + test_wrapped.__name__ = func.__name__ + + def setup_wrapped(): + k = setup() + kwargs.update(k) + if hasattr(func, 'setup'): + func.setup() + test_wrapped.setup = setup_wrapped + + if teardown: + def teardown_wrapped(): + if hasattr(func, 'teardown'): + func.teardown() + teardown(**kwargs) + + test_wrapped.teardown = teardown_wrapped + else: + if hasattr(func, 'teardown'): + test_wrapped.teardown = func.teardown() + return test_wrapped + return decorate + +# Demo case for the above, when you run test_gen(): +# _test_gen will run twice, +# with the following stderr printing +# setup_func {'b': 2} +# testcase ('1',) {'b': 2, 'testname': '_test_gen'} +# teardown_func {'b': 2} +# setup_func {'b': 2} +# testcase () {'b': 2, 'testname': '_test_gen'} +# teardown_func {'b': 2} +# +#def setup_func(): +# kwargs = {'b': 2} +# print("setup_func", kwargs, file=sys.stderr) +# return kwargs +# +#def teardown_func(**kwargs): +# print("teardown_func", kwargs, file=sys.stderr) +# +#@with_setup_kwargs(setup=setup_func, teardown=teardown_func) +#def _test_gen(*args, **kwargs): +# print("testcase", args, kwargs, file=sys.stderr) +# +#def test_gen(): +# yield _test_gen, '1' +# yield _test_gen + +def trim_xml(xml_str): + p = etree.XMLParser(remove_blank_text=True) + elem = etree.XML(xml_str, parser=p) + return etree.tostring(elem) + +def normalize_xml(xml, pretty_print=True): + if xml is None: + return xml + + root = etree.fromstring(xml.encode(encoding='ascii')) + + for element in root.iter('*'): + if element.text is not None and not element.text.strip(): + element.text = None + if element.text is not None: + element.text = element.text.strip().replace("\n", "").replace("\r", "") + if element.tail is not None and not element.tail.strip(): + element.tail = None + if element.tail is not None: + element.tail = element.tail.strip().replace("\n", "").replace("\r", "") + + # Sort the elements + for parent in root.xpath('//*[./*]'): # Search for parent elements + parent[:] = sorted(parent,key=lambda x: x.tag) + + xmlstr = etree.tostring(root, encoding="utf-8", xml_declaration=True, pretty_print=pretty_print) + # there are two different DTD URIs + xmlstr = re.sub(r'xmlns="[^"]+"', 'xmlns="s3"', xmlstr) + xmlstr = re.sub(r'xmlns=\'[^\']+\'', 'xmlns="s3"', xmlstr) + for uri in ['http://doc.s3.amazonaws.com/doc/2006-03-01/', 'http://s3.amazonaws.com/doc/2006-03-01/']: + xmlstr = xmlstr.replace(uri, 'URI-DTD') + #xmlstr = re.sub(r'>\s+', '>', xmlstr, count=0, flags=re.MULTILINE) + return xmlstr + +def assert_xml_equal(got, want): + assert want is not None, 'Wanted XML cannot be None' + if got is None: + raise AssertionError('Got input to validate was None') + checker = LXMLOutputChecker() + if not checker.check_output(want, got, 0): + message = checker.output_difference(Example("", want), got, 0) + raise AssertionError(message) diff --git a/src/test/rgw/s3-tests/s3tests/functional/__init__.py b/src/test/rgw/s3-tests/s3tests/functional/__init__.py new file mode 100644 index 00000000000..5fcb1f47121 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/__init__.py @@ -0,0 +1,883 @@ +import pytest +import boto3 +from botocore import UNSIGNED +from botocore.client import Config +from botocore.exceptions import ClientError +from botocore.handlers import disable_signing +import configparser +import datetime +import time +import os +import munch +import random +import string +import itertools +import urllib3 +import re + +config = munch.Munch + +# this will be assigned by setup() +prefix = None + +def get_prefix(): + assert prefix is not None + return prefix + +def choose_bucket_prefix(template, max_len=30): + """ + Choose a prefix for our test buckets, so they're easy to identify. + + Use template and feed it more and more random filler, until it's + as long as possible but still below max_len. + """ + rand = ''.join( + random.choice(string.ascii_lowercase + string.digits) + for c in range(255) + ) + + while rand: + s = template.format(random=rand) + if len(s) <= max_len: + return s + rand = rand[:-1] + + raise RuntimeError( + 'Bucket prefix template is impossible to fulfill: {template!r}'.format( + template=template, + ), + ) + +def get_buckets_list(client=None, prefix=None): + if client == None: + client = get_client() + if prefix == None: + prefix = get_prefix() + response = client.list_buckets() + bucket_dicts = response['Buckets'] + buckets_list = [] + for bucket in bucket_dicts: + if prefix in bucket['Name']: + buckets_list.append(bucket['Name']) + + return buckets_list + +def get_objects_list(bucket, client=None, prefix=None): + if client == None: + client = get_client() + + if prefix == None: + response = client.list_objects(Bucket=bucket) + else: + response = client.list_objects(Bucket=bucket, Prefix=prefix) + objects_list = [] + + if 'Contents' in response: + contents = response['Contents'] + for obj in contents: + objects_list.append(obj['Key']) + + return objects_list + +# generator function that returns object listings in batches, where each +# batch is a list of dicts compatible with delete_objects() +def list_versions(client, bucket, batch_size): + kwargs = {'Bucket': bucket, 'MaxKeys': batch_size} + truncated = True + while truncated: + listing = client.list_object_versions(**kwargs) + + kwargs['KeyMarker'] = listing.get('NextKeyMarker') + kwargs['VersionIdMarker'] = listing.get('NextVersionIdMarker') + truncated = listing['IsTruncated'] + + objs = listing.get('Versions', []) + listing.get('DeleteMarkers', []) + if len(objs): + yield [{'Key': o['Key'], 'VersionId': o['VersionId']} for o in objs] + +def nuke_bucket(client, bucket): + batch_size = 128 + max_retain_date = None + + # list and delete objects in batches + for objects in list_versions(client, bucket, batch_size): + delete = client.delete_objects(Bucket=bucket, + Delete={'Objects': objects, 'Quiet': True}, + BypassGovernanceRetention=True) + + # check for object locks on 403 AccessDenied errors + for err in delete.get('Errors', []): + if err.get('Code') != 'AccessDenied': + continue + try: + res = client.get_object_retention(Bucket=bucket, + Key=err['Key'], VersionId=err['VersionId']) + retain_date = res['Retention']['RetainUntilDate'] + if not max_retain_date or max_retain_date < retain_date: + max_retain_date = retain_date + except ClientError: + pass + + if max_retain_date: + # wait out the retention period (up to 60 seconds) + now = datetime.datetime.now(max_retain_date.tzinfo) + if max_retain_date > now: + delta = max_retain_date - now + if delta.total_seconds() > 60: + raise RuntimeError('bucket {} still has objects \ +locked for {} more seconds, not waiting for \ +bucket cleanup'.format(bucket, delta.total_seconds())) + print('nuke_bucket', bucket, 'waiting', delta.total_seconds(), + 'seconds for object locks to expire') + time.sleep(delta.total_seconds()) + + for objects in list_versions(client, bucket, batch_size): + client.delete_objects(Bucket=bucket, + Delete={'Objects': objects, 'Quiet': True}, + BypassGovernanceRetention=True) + + client.delete_bucket(Bucket=bucket) + +def nuke_prefixed_buckets(prefix, client=None): + if client == None: + client = get_client() + + buckets = get_buckets_list(client, prefix) + + err = None + for bucket_name in buckets: + try: + nuke_bucket(client, bucket_name) + except Exception as e: + # The exception shouldn't be raised when doing cleanup. Pass and continue + # the bucket cleanup process. Otherwise left buckets wouldn't be cleared + # resulting in some kind of resource leak. err is used to hint user some + # exception once occurred. + err = e + pass + if err: + raise err + + print('Done with cleanup of buckets in tests.') + +def configured_storage_classes(): + sc = ['STANDARD'] + + extra_sc = re.split(r"[\b\W\b]+", config.storage_classes) + + for item in extra_sc: + if item != 'STANDARD': + sc.append(item) + + sc = [i for i in sc if i] + print("storage classes configured: " + str(sc)) + + return sc + +def configure(): + cfg = configparser.RawConfigParser() + try: + path = os.environ['S3TEST_CONF'] + except KeyError: + raise RuntimeError( + 'To run tests, point environment ' + + 'variable S3TEST_CONF to a config file.', + ) + cfg.read(path) + + if not cfg.defaults(): + raise RuntimeError('Your config file is missing the DEFAULT section!') + if not cfg.has_section("s3 main"): + raise RuntimeError('Your config file is missing the "s3 main" section!') + if not cfg.has_section("s3 alt"): + raise RuntimeError('Your config file is missing the "s3 alt" section!') + if not cfg.has_section("s3 tenant"): + raise RuntimeError('Your config file is missing the "s3 tenant" section!') + + global prefix + + defaults = cfg.defaults() + + # vars from the DEFAULT section + config.default_host = defaults.get("host") + config.default_port = int(defaults.get("port")) + config.default_is_secure = cfg.getboolean('DEFAULT', "is_secure") + + proto = 'https' if config.default_is_secure else 'http' + config.default_endpoint = "%s://%s:%d" % (proto, config.default_host, config.default_port) + + try: + config.default_ssl_verify = cfg.getboolean('DEFAULT', "ssl_verify") + except configparser.NoOptionError: + config.default_ssl_verify = False + + # Disable InsecureRequestWarning reported by urllib3 when ssl_verify is False + if not config.default_ssl_verify: + urllib3.disable_warnings() + + # vars from the main section + config.main_access_key = cfg.get('s3 main',"access_key") + config.main_secret_key = cfg.get('s3 main',"secret_key") + config.main_display_name = cfg.get('s3 main',"display_name") + config.main_user_id = cfg.get('s3 main',"user_id") + config.main_email = cfg.get('s3 main',"email") + config.main_account_id = cfg.get('s3 main', 'account_id', fallback=None) + try: + config.main_kms_keyid = cfg.get('s3 main',"kms_keyid") + except (configparser.NoSectionError, configparser.NoOptionError): + config.main_kms_keyid = 'testkey-1' + + try: + config.main_kms_keyid2 = cfg.get('s3 main',"kms_keyid2") + except (configparser.NoSectionError, configparser.NoOptionError): + config.main_kms_keyid2 = 'testkey-2' + + try: + config.main_api_name = cfg.get('s3 main',"api_name") + except (configparser.NoSectionError, configparser.NoOptionError): + config.main_api_name = "" + pass + + try: + config.storage_classes = cfg.get('s3 main',"storage_classes") + except (configparser.NoSectionError, configparser.NoOptionError): + config.storage_classes = "" + pass + + try: + config.lc_debug_interval = int(cfg.get('s3 main',"lc_debug_interval")) + except (configparser.NoSectionError, configparser.NoOptionError): + config.lc_debug_interval = 10 + + try: + config.rgw_restore_debug_interval = int(cfg.get('s3 main',"rgw_restore_debug_interval")) + except (configparser.NoSectionError, configparser.NoOptionError): + config.rgw_restore_debug_interval = 100 + + try: + config.rgw_restore_processor_period = int(cfg.get('s3 main',"rgw_restore_processor_period")) + except (configparser.NoSectionError, configparser.NoOptionError): + config.rgw_restore_processor_period = 100 + + config.alt_access_key = cfg.get('s3 alt',"access_key") + config.alt_secret_key = cfg.get('s3 alt',"secret_key") + config.alt_display_name = cfg.get('s3 alt',"display_name") + config.alt_user_id = cfg.get('s3 alt',"user_id") + config.alt_email = cfg.get('s3 alt',"email") + config.alt_account_id = cfg.get('s3 alt', 'account_id', fallback=None) + + config.tenant_access_key = cfg.get('s3 tenant',"access_key") + config.tenant_secret_key = cfg.get('s3 tenant',"secret_key") + config.tenant_display_name = cfg.get('s3 tenant',"display_name") + config.tenant_user_id = cfg.get('s3 tenant',"user_id") + config.tenant_email = cfg.get('s3 tenant',"email") + config.tenant_account_id = cfg.get('s3 tenant', 'account_id', fallback=None) + config.tenant_name = cfg.get('s3 tenant',"tenant") + + config.iam_access_key = cfg.get('iam',"access_key") + config.iam_secret_key = cfg.get('iam',"secret_key") + config.iam_display_name = cfg.get('iam',"display_name") + config.iam_user_id = cfg.get('iam',"user_id") + config.iam_email = cfg.get('iam',"email") + + config.iam_root_access_key = cfg.get('iam root',"access_key") + config.iam_root_secret_key = cfg.get('iam root',"secret_key") + config.iam_root_user_id = cfg.get('iam root',"user_id") + config.iam_root_email = cfg.get('iam root',"email") + config.iam_root_account_id = cfg.get('iam root', 'account_id', fallback=None) + + config.iam_alt_root_access_key = cfg.get('iam alt root',"access_key") + config.iam_alt_root_secret_key = cfg.get('iam alt root',"secret_key") + config.iam_alt_root_user_id = cfg.get('iam alt root',"user_id") + config.iam_alt_root_email = cfg.get('iam alt root',"email") + config.iam_alt_root_account_id = cfg.get('iam alt root', 'account_id', fallback=None) + + # vars from the fixtures section + template = cfg.get('fixtures', "bucket prefix", fallback='test-{random}-') + prefix = choose_bucket_prefix(template=template) + template = cfg.get('fixtures', "iam name prefix", fallback="s3-tests-") + config.iam_name_prefix = choose_bucket_prefix(template=template) + template = cfg.get('fixtures', "iam path prefix", fallback="/s3-tests/") + config.iam_path_prefix = choose_bucket_prefix(template=template) + + if cfg.has_section("s3 cloud"): + get_cloud_config(cfg) + else: + config.cloud_storage_class = None + +def setup(): + alt_client = get_alt_client() + tenant_client = get_tenant_client() + nuke_prefixed_buckets(prefix=prefix) + nuke_prefixed_buckets(prefix=prefix, client=alt_client) + nuke_prefixed_buckets(prefix=prefix, client=tenant_client) + +def teardown(): + alt_client = get_alt_client() + tenant_client = get_tenant_client() + nuke_prefixed_buckets(prefix=prefix) + nuke_prefixed_buckets(prefix=prefix, client=alt_client) + nuke_prefixed_buckets(prefix=prefix, client=tenant_client) + try: + iam_client = get_iam_client() + list_roles_resp = iam_client.list_roles() + for role in list_roles_resp['Roles']: + list_policies_resp = iam_client.list_role_policies(RoleName=role['RoleName']) + for policy in list_policies_resp['PolicyNames']: + del_policy_resp = iam_client.delete_role_policy( + RoleName=role['RoleName'], + PolicyName=policy + ) + del_role_resp = iam_client.delete_role(RoleName=role['RoleName']) + list_oidc_resp = iam_client.list_open_id_connect_providers() + for oidcprovider in list_oidc_resp['OpenIDConnectProviderList']: + del_oidc_resp = iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidcprovider['Arn'] + ) + except: + pass + +@pytest.fixture(scope="package") +def configfile(): + configure() + return config + +@pytest.fixture(autouse=True) +def setup_teardown(configfile): + setup() + yield + teardown() + +def check_webidentity(): + cfg = configparser.RawConfigParser() + try: + path = os.environ['S3TEST_CONF'] + except KeyError: + raise RuntimeError( + 'To run tests, point environment ' + + 'variable S3TEST_CONF to a config file.', + ) + cfg.read(path) + if not cfg.has_section("webidentity"): + raise RuntimeError('Your config file is missing the "webidentity" section!') + + config.webidentity_thumbprint = cfg.get('webidentity', "thumbprint") + config.webidentity_aud = cfg.get('webidentity', "aud") + config.webidentity_token = cfg.get('webidentity', "token") + config.webidentity_realm = cfg.get('webidentity', "KC_REALM") + config.webidentity_sub = cfg.get('webidentity', "sub") + config.webidentity_azp = cfg.get('webidentity', "azp") + config.webidentity_user_token = cfg.get('webidentity', "user_token") + +def get_cloud_config(cfg): + config.cloud_host = cfg.get('s3 cloud',"host") + config.cloud_port = int(cfg.get('s3 cloud',"port")) + config.cloud_is_secure = cfg.getboolean('s3 cloud', "is_secure") + + proto = 'https' if config.cloud_is_secure else 'http' + config.cloud_endpoint = "%s://%s:%d" % (proto, config.cloud_host, config.cloud_port) + + config.cloud_access_key = cfg.get('s3 cloud',"access_key") + config.cloud_secret_key = cfg.get('s3 cloud',"secret_key") + + try: + config.cloud_storage_class = cfg.get('s3 cloud', "cloud_storage_class") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_storage_class = None + + try: + config.cloud_retain_head_object = cfg.get('s3 cloud',"retain_head_object") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_retain_head_object = None + + try: + config.allow_read_through = cfg.get('s3 cloud',"allow_read_through") + except (configparser.NoSectionError, configparser.NoOptionError): + config.allow_read_through = False + + try: + config.cloud_target_path = cfg.get('s3 cloud',"target_path") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_target_path = None + + try: + config.cloud_target_storage_class = cfg.get('s3 cloud',"target_storage_class") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_target_storage_class = 'STANDARD' + + try: + config.cloud_regular_storage_class = cfg.get('s3 cloud', "storage_class") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_regular_storage_class = None + + try: + config.read_through_restore_days = int(cfg.get('s3 cloud', "read_through_restore_days")) + except (configparser.NoSectionError, configparser.NoOptionError): + config.read_through_restore_days = 10 + + try: + config.cloud_target_by_bucket = cfg.getboolean('s3 cloud', "target_by_bucket") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_target_by_bucket = False + + try: + config.cloud_target_by_bucket_prefix = cfg.get('s3 cloud', "target_by_bucket_prefix") + except (configparser.NoSectionError, configparser.NoOptionError): + config.cloud_target_by_bucket_prefix = None + + +def get_client(client_config=None): + if client_config == None: + client_config = Config(signature_version='s3v4') + + client = boto3.client(service_name='s3', + aws_access_key_id=config.main_access_key, + aws_secret_access_key=config.main_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=client_config) + return client + +def get_v2_client(): + client = boto3.client(service_name='s3', + aws_access_key_id=config.main_access_key, + aws_secret_access_key=config.main_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=Config(signature_version='s3')) + return client + +def get_sts_client(**kwargs): + kwargs.setdefault('region_name', '') + kwargs.setdefault('aws_access_key_id', config.alt_access_key) + kwargs.setdefault('aws_secret_access_key', config.alt_secret_key) + kwargs.setdefault('config', Config(signature_version='s3v4')) + + client = boto3.client(service_name='sts', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + return client + +def get_iam_client(**kwargs): + kwargs.setdefault('region_name', '') + kwargs.setdefault('aws_access_key_id', config.iam_access_key) + kwargs.setdefault('aws_secret_access_key', config.iam_secret_key) + + client = boto3.client(service_name='iam', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + return client + +def get_iam_s3client(**kwargs): + kwargs.setdefault('aws_access_key_id', config.iam_access_key) + kwargs.setdefault('aws_secret_access_key', config.iam_secret_key) + kwargs.setdefault('config', Config(signature_version='s3v4')) + + client = boto3.client(service_name='s3', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + return client + +def get_iam_root_s3client(**kwargs): + kwargs.setdefault('aws_access_key_id', config.iam_root_access_key) + kwargs.setdefault('aws_secret_access_key', config.iam_root_secret_key) + kwargs.setdefault('config', Config(signature_version='s3v4')) + + client = boto3.client(service_name='s3', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + return client + +def get_iam_root_client(**kwargs): + kwargs.setdefault('service_name', 'iam') + kwargs.setdefault('region_name', '') + kwargs.setdefault('aws_access_key_id', config.iam_root_access_key) + kwargs.setdefault('aws_secret_access_key', config.iam_root_secret_key) + + return boto3.client(endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + +def get_iam_alt_root_client(**kwargs): + kwargs.setdefault('service_name', 'iam') + kwargs.setdefault('region_name', '') + kwargs.setdefault('aws_access_key_id', config.iam_alt_root_access_key) + kwargs.setdefault('aws_secret_access_key', config.iam_alt_root_secret_key) + + return boto3.client(endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + **kwargs) + +def get_alt_client(client_config=None): + if client_config == None: + client_config = Config(signature_version='s3v4') + + client = boto3.client(service_name='s3', + aws_access_key_id=config.alt_access_key, + aws_secret_access_key=config.alt_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=client_config) + return client + +def get_cloud_client(client_config=None): + if client_config == None: + client_config = Config(signature_version='s3v4') + + client = boto3.client(service_name='s3', + aws_access_key_id=config.cloud_access_key, + aws_secret_access_key=config.cloud_secret_key, + endpoint_url=config.cloud_endpoint, + use_ssl=config.cloud_is_secure, + config=client_config) + return client + +def get_tenant_client(client_config=None): + if client_config == None: + client_config = Config(signature_version='s3v4') + + client = boto3.client(service_name='s3', + aws_access_key_id=config.tenant_access_key, + aws_secret_access_key=config.tenant_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=client_config) + return client + +def get_v2_tenant_client(): + client_config = Config(signature_version='s3') + client = boto3.client(service_name='s3', + aws_access_key_id=config.tenant_access_key, + aws_secret_access_key=config.tenant_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=client_config) + return client + +def get_tenant_iam_client(): + + client = boto3.client(service_name='iam', + region_name='us-east-1', + aws_access_key_id=config.tenant_access_key, + aws_secret_access_key=config.tenant_secret_key, + endpoint_url=config.default_endpoint, + verify=config.default_ssl_verify, + use_ssl=config.default_is_secure) + return client + +def get_alt_iam_client(): + + client = boto3.client(service_name='iam', + region_name='', + aws_access_key_id=config.alt_access_key, + aws_secret_access_key=config.alt_secret_key, + endpoint_url=config.default_endpoint, + verify=config.default_ssl_verify, + use_ssl=config.default_is_secure) + return client + +def get_unauthenticated_client(): + client = boto3.client(service_name='s3', + aws_access_key_id='', + aws_secret_access_key='', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=Config(signature_version=UNSIGNED)) + return client + +def get_bad_auth_client(aws_access_key_id='badauth'): + client = boto3.client(service_name='s3', + aws_access_key_id=aws_access_key_id, + aws_secret_access_key='roflmao', + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=Config(signature_version='s3v4')) + return client + +def get_svc_client(client_config=None, svc='s3'): + if client_config == None: + client_config = Config(signature_version='s3v4') + + client = boto3.client(service_name=svc, + aws_access_key_id=config.main_access_key, + aws_secret_access_key=config.main_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify, + config=client_config) + return client + +bucket_counter = itertools.count(1) + +def get_new_bucket_name(): + """ + Get a bucket name that probably does not exist. + + We make every attempt to use a unique random prefix, so if a + bucket by this name happens to exist, it's ok if tests give + false negatives. + """ + name = '{prefix}{num}'.format( + prefix=prefix, + num=next(bucket_counter), + ) + return name + +def get_new_bucket_resource(name=None): + """ + Get a bucket that exists and is empty. + + Always recreates a bucket from scratch. This is useful to also + reset ACLs and such. + """ + s3 = boto3.resource('s3', + aws_access_key_id=config.main_access_key, + aws_secret_access_key=config.main_secret_key, + endpoint_url=config.default_endpoint, + use_ssl=config.default_is_secure, + verify=config.default_ssl_verify) + if name is None: + name = get_new_bucket_name() + bucket = s3.Bucket(name) + bucket_location = bucket.create() + return bucket + +def get_new_bucket(client=None, name=None): + """ + Get a bucket that exists and is empty. + + Always recreates a bucket from scratch. This is useful to also + reset ACLs and such. + """ + if client is None: + client = get_client() + if name is None: + name = get_new_bucket_name() + + client.create_bucket(Bucket=name) + return name + +def get_parameter_name(): + parameter_name="" + rand = ''.join( + random.choice(string.ascii_lowercase + string.digits) + for c in range(255) + ) + while rand: + parameter_name = '{random}'.format(random=rand) + if len(parameter_name) <= 10: + return parameter_name + rand = rand[:-1] + return parameter_name + +def get_sts_user_id(): + return config.alt_user_id + +def get_config_is_secure(): + return config.default_is_secure + +def get_config_host(): + return config.default_host + +def get_config_port(): + return config.default_port + +def get_config_endpoint(): + return config.default_endpoint + +def get_config_ssl_verify(): + return config.default_ssl_verify + +def get_main_aws_access_key(): + return config.main_access_key + +def get_main_aws_secret_key(): + return config.main_secret_key + +def get_main_display_name(): + return config.main_display_name + +def get_main_user_id(): + return config.main_user_id + +def get_main_email(): + return config.main_email + +def get_main_account_id(): + return config.main_account_id + +def get_main_api_name(): + return config.main_api_name + +def get_main_kms_keyid(): + return config.main_kms_keyid + +def get_secondary_kms_keyid(): + return config.main_kms_keyid2 + +def get_alt_aws_access_key(): + return config.alt_access_key + +def get_alt_aws_secret_key(): + return config.alt_secret_key + +def get_alt_display_name(): + return config.alt_display_name + +def get_alt_user_id(): + return config.alt_user_id + +def get_alt_account_id(): + return config.alt_account_id + +def get_alt_email(): + return config.alt_email + +def get_tenant_aws_access_key(): + return config.tenant_access_key + +def get_tenant_aws_secret_key(): + return config.tenant_secret_key + +def get_tenant_display_name(): + return config.tenant_display_name + +def get_tenant_account_id(): + return config.tenant_account_id + +def get_tenant_name(): + return config.tenant_name + +def get_tenant_user_id(): + return config.tenant_user_id + +def get_tenant_email(): + return config.tenant_email + +def get_thumbprint(): + return config.webidentity_thumbprint + +def get_aud(): + return config.webidentity_aud + +def get_sub(): + return config.webidentity_sub + +def get_azp(): + return config.webidentity_azp + +def get_token(): + return config.webidentity_token + +def get_realm_name(): + return config.webidentity_realm + +def get_iam_name_prefix(): + return config.iam_name_prefix + +def make_iam_name(name): + return config.iam_name_prefix + name + +def get_iam_path_prefix(): + return config.iam_path_prefix + +def get_iam_access_key(): + return config.iam_access_key + +def get_iam_secret_key(): + return config.iam_secret_key + +def get_iam_root_user_id(): + return config.iam_root_user_id + +def get_iam_root_email(): + return config.iam_root_email + +def get_iam_root_account_id(): + return config.iam_root_account_id + +def get_iam_alt_root_user_id(): + return config.iam_alt_root_user_id + +def get_iam_alt_root_email(): + return config.iam_alt_root_email + +def get_iam_alt_root_account_id(): + return config.iam_alt_root_account_id + +def get_user_token(): + return config.webidentity_user_token + +def get_cloud_storage_class(): + return config.cloud_storage_class + +def get_cloud_retain_head_object(): + return config.cloud_retain_head_object + +def get_allow_read_through(): + return config.allow_read_through + +def get_cloud_regular_storage_class(): + return config.cloud_regular_storage_class + +def get_cloud_target_path(): + return config.cloud_target_path + +def get_cloud_target_storage_class(): + return config.cloud_target_storage_class + +def get_lc_debug_interval(): + return config.lc_debug_interval + +def get_restore_debug_interval(): + return config.rgw_restore_debug_interval + +def get_restore_processor_period(): + return config.rgw_restore_processor_period + +def get_read_through_days(): + return config.read_through_restore_days + +def get_cloud_target_by_bucket(): + return config.cloud_target_by_bucket + +def get_cloud_target_by_bucket_prefix(): + return config.cloud_target_by_bucket_prefix + +def create_iam_user_s3client(client): + prefix = get_iam_path_prefix() + + # generate random name + randname = ''.join( + random.choice(string.ascii_lowercase + string.digits) + for c in range(8) + ) + name = make_iam_name(randname) + + user = client.create_user(UserName=name, Path=prefix) + + # create s3 access and secret keys + keys = client.create_access_key(UserName=user['User']['UserName']) + + # create s3 client + return get_iam_s3client( + aws_access_key_id=keys['AccessKey']['AccessKeyId'], + aws_secret_access_key=keys['AccessKey']['SecretAccessKey'], + ) diff --git a/src/test/rgw/s3-tests/s3tests/functional/iam.py b/src/test/rgw/s3-tests/s3tests/functional/iam.py new file mode 100644 index 00000000000..a070e5d84d9 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/iam.py @@ -0,0 +1,199 @@ +from botocore.exceptions import ClientError +import pytest + +from . import ( + configfile, + get_iam_root_client, + get_iam_root_user_id, + get_iam_root_email, + get_iam_alt_root_client, + get_iam_alt_root_user_id, + get_iam_alt_root_email, + get_iam_path_prefix, +) + +def nuke_user_keys(client, name): + p = client.get_paginator('list_access_keys') + for response in p.paginate(UserName=name): + for key in response['AccessKeyMetadata']: + try: + client.delete_access_key(UserName=name, AccessKeyId=key['AccessKeyId']) + except: + pass + +def nuke_user_policies(client, name): + p = client.get_paginator('list_user_policies') + for response in p.paginate(UserName=name): + for policy in response['PolicyNames']: + try: + client.delete_user_policy(UserName=name, PolicyName=policy) + except: + pass + +def nuke_attached_user_policies(client, name): + p = client.get_paginator('list_attached_user_policies') + for response in p.paginate(UserName=name): + for policy in response['AttachedPolicies']: + try: + client.detach_user_policy(UserName=name, PolicyArn=policy['PolicyArn']) + except: + pass + +def nuke_user(client, name): + # delete access keys, user policies, etc + try: + nuke_user_keys(client, name) + except: + pass + try: + nuke_user_policies(client, name) + except: + pass + try: + nuke_attached_user_policies(client, name) + except: + pass + client.delete_user(UserName=name) + +def nuke_users(client, **kwargs): + p = client.get_paginator('list_users') + for response in p.paginate(**kwargs): + for user in response['Users']: + try: + nuke_user(client, user['UserName']) + except: + pass + +def nuke_group_policies(client, name): + p = client.get_paginator('list_group_policies') + for response in p.paginate(GroupName=name): + for policy in response['PolicyNames']: + try: + client.delete_group_policy(GroupName=name, PolicyName=policy) + except: + pass + +def nuke_attached_group_policies(client, name): + p = client.get_paginator('list_attached_group_policies') + for response in p.paginate(GroupName=name): + for policy in response['AttachedPolicies']: + try: + client.detach_group_policy(GroupName=name, PolicyArn=policy['PolicyArn']) + except: + pass + +def nuke_group_users(client, name): + p = client.get_paginator('get_group') + for response in p.paginate(GroupName=name): + for user in response['Users']: + try: + client.remove_user_from_group(GroupName=name, UserName=user['UserName']) + except: + pass + +def nuke_group(client, name): + # delete group policies and remove all users + try: + nuke_group_policies(client, name) + except: + pass + try: + nuke_attached_group_policies(client, name) + except: + pass + try: + nuke_group_users(client, name) + except: + pass + client.delete_group(GroupName=name) + +def nuke_groups(client, **kwargs): + p = client.get_paginator('list_groups') + for response in p.paginate(**kwargs): + for user in response['Groups']: + try: + nuke_group(client, user['GroupName']) + except: + pass + +def nuke_role_policies(client, name): + p = client.get_paginator('list_role_policies') + for response in p.paginate(RoleName=name): + for policy in response['PolicyNames']: + try: + client.delete_role_policy(RoleName=name, PolicyName=policy) + except: + pass + +def nuke_attached_role_policies(client, name): + p = client.get_paginator('list_attached_role_policies') + for response in p.paginate(RoleName=name): + for policy in response['AttachedPolicies']: + try: + client.detach_role_policy(RoleName=name, PolicyArn=policy['PolicyArn']) + except: + pass + +def nuke_role(client, name): + # delete role policies, etc + try: + nuke_role_policies(client, name) + except: + pass + try: + nuke_attached_role_policies(client, name) + except: + pass + client.delete_role(RoleName=name) + +def nuke_roles(client, **kwargs): + p = client.get_paginator('list_roles') + for response in p.paginate(**kwargs): + for role in response['Roles']: + try: + nuke_role(client, role['RoleName']) + except: + pass + +def nuke_oidc_providers(client, prefix): + result = client.list_open_id_connect_providers() + for provider in result['OpenIDConnectProviderList']: + arn = provider['Arn'] + if f':oidc-provider{prefix}' in arn: + try: + client.delete_open_id_connect_provider(OpenIDConnectProviderArn=arn) + except: + pass + + +# fixture for iam account root user +@pytest.fixture +def iam_root(configfile): + client = get_iam_root_client() + try: + arn = client.get_user()['User']['Arn'] + if not arn.endswith(':root'): + pytest.skip('[iam root] user does not have :root arn') + except ClientError as e: + pytest.skip('[iam root] user does not belong to an account') + + yield client + nuke_users(client, PathPrefix=get_iam_path_prefix()) + nuke_groups(client, PathPrefix=get_iam_path_prefix()) + nuke_roles(client, PathPrefix=get_iam_path_prefix()) + nuke_oidc_providers(client, get_iam_path_prefix()) + +# fixture for iam alt account root user +@pytest.fixture +def iam_alt_root(configfile): + client = get_iam_alt_root_client() + try: + arn = client.get_user()['User']['Arn'] + if not arn.endswith(':root'): + pytest.skip('[iam alt root] user does not have :root arn') + except ClientError as e: + pytest.skip('[iam alt root] user does not belong to an account') + + yield client + nuke_users(client, PathPrefix=get_iam_path_prefix()) + nuke_roles(client, PathPrefix=get_iam_path_prefix()) diff --git a/src/test/rgw/s3-tests/s3tests/functional/policy.py b/src/test/rgw/s3-tests/s3tests/functional/policy.py new file mode 100644 index 00000000000..123496afc2f --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/policy.py @@ -0,0 +1,46 @@ +import json + +class Statement(object): + def __init__(self, action, resource, principal = {"AWS" : "*"}, effect= "Allow", condition = None): + self.principal = principal + self.action = action + self.resource = resource + self.condition = condition + self.effect = effect + + def to_dict(self): + d = { "Action" : self.action, + "Principal" : self.principal, + "Effect" : self.effect, + "Resource" : self.resource + } + + if self.condition is not None: + d["Condition"] = self.condition + + return d + +class Policy(object): + def __init__(self): + self.statements = [] + + def add_statement(self, s): + self.statements.append(s) + return self + + def to_json(self): + policy_dict = { + "Version" : "2012-10-17", + "Statement": + [s.to_dict() for s in self.statements] + } + + return json.dumps(policy_dict) + +def make_json_policy(action, resource, principal={"AWS": "*"}, effect="Allow", conditions=None): + """ + Helper function to make single statement policies + """ + s = Statement(action, resource, principal, effect=effect, condition=conditions) + p = Policy() + return p.add_statement(s).to_json() diff --git a/src/test/rgw/s3-tests/s3tests/functional/rgw_interactive.py b/src/test/rgw/s3-tests/s3tests/functional/rgw_interactive.py new file mode 100644 index 00000000000..873a145911c --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/rgw_interactive.py @@ -0,0 +1,92 @@ +#!/usr/bin/python +import boto3 +import os +import random +import string +import itertools + +host = "localhost" +port = 8000 + +## AWS access key +access_key = "0555b35654ad1656d804" + +## AWS secret key +secret_key = "h7GhxuBLTrlhVUyxSPUKUV8r/2EI4ngqJxD7iBdBYLhwluN30JaT3Q==" + +prefix = "YOURNAMEHERE-1234-" + +endpoint_url = "http://%s:%d" % (host, port) + +client = boto3.client(service_name='s3', + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + endpoint_url=endpoint_url, + use_ssl=False, + verify=False) + +s3 = boto3.resource('s3', + use_ssl=False, + verify=False, + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key) + +def choose_bucket_prefix(template, max_len=30): + """ + Choose a prefix for our test buckets, so they're easy to identify. + + Use template and feed it more and more random filler, until it's + as long as possible but still below max_len. + """ + rand = ''.join( + random.choice(string.ascii_lowercase + string.digits) + for c in range(255) + ) + + while rand: + s = template.format(random=rand) + if len(s) <= max_len: + return s + rand = rand[:-1] + + raise RuntimeError( + 'Bucket prefix template is impossible to fulfill: {template!r}'.format( + template=template, + ), + ) + +bucket_counter = itertools.count(1) + +def get_new_bucket_name(): + """ + Get a bucket name that probably does not exist. + + We make every attempt to use a unique random prefix, so if a + bucket by this name happens to exist, it's ok if tests give + false negatives. + """ + name = '{prefix}{num}'.format( + prefix=prefix, + num=next(bucket_counter), + ) + return name + +def get_new_bucket(session=boto3, name=None, headers=None): + """ + Get a bucket that exists and is empty. + + Always recreates a bucket from scratch. This is useful to also + reset ACLs and such. + """ + s3 = session.resource('s3', + use_ssl=False, + verify=False, + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key) + if name is None: + name = get_new_bucket_name() + bucket = s3.Bucket(name) + bucket_location = bucket.create() + return bucket diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_headers.py b/src/test/rgw/s3-tests/s3tests/functional/test_headers.py new file mode 100644 index 00000000000..360a2e6643b --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_headers.py @@ -0,0 +1,578 @@ +import boto3 +import pytest +import botocore.config +from botocore.exceptions import ClientError +from email.utils import formatdate + +from .utils import assert_raises +from .utils import _get_status_and_error_code +from .utils import _get_status + +from . import ( + configfile, + setup_teardown, + get_client, + get_v2_client, + get_new_bucket, + get_new_bucket_name, + ) + +def _add_header_create_object(headers, client=None): + """ Create a new bucket, add an object w/header customizations + """ + bucket_name = get_new_bucket() + if client == None: + client = get_client() + key_name = 'foo' + + # pass in custom headers before PutObject call + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.PutObject', add_headers) + client.put_object(Bucket=bucket_name, Key=key_name) + + return bucket_name, key_name + + +def _add_header_create_bad_object(headers, client=None): + """ Create a new bucket, add an object with a header. This should cause a failure + """ + bucket_name = get_new_bucket() + if client == None: + client = get_client() + key_name = 'foo' + + # pass in custom headers before PutObject call + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.PutObject', add_headers) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key_name, Body='bar') + + return e + + +def _remove_header_create_object(remove, client=None): + """ Create a new bucket, add an object without a header + """ + bucket_name = get_new_bucket() + if client == None: + client = get_client() + key_name = 'foo' + + # remove custom headers before PutObject call + def remove_header(**kwargs): + if (remove in kwargs['params']['headers']): + del kwargs['params']['headers'][remove] + + client.meta.events.register('before-call.s3.PutObject', remove_header) + client.put_object(Bucket=bucket_name, Key=key_name) + + return bucket_name, key_name + +def _remove_header_create_bad_object(remove, client=None): + """ Create a new bucket, add an object without a header. This should cause a failure + """ + bucket_name = get_new_bucket() + if client == None: + client = get_client() + key_name = 'foo' + + # remove custom headers before PutObject call + def remove_header(**kwargs): + if (remove in kwargs['params']['headers']): + del kwargs['params']['headers'][remove] + + client.meta.events.register('before-call.s3.PutObject', remove_header) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key_name, Body='bar') + + return e + + +def _add_header_create_bucket(headers, client=None): + """ Create a new bucket, w/header customizations + """ + bucket_name = get_new_bucket_name() + if client == None: + client = get_client() + + # pass in custom headers before PutObject call + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.CreateBucket', add_headers) + client.create_bucket(Bucket=bucket_name) + + return bucket_name + + +def _add_header_create_bad_bucket(headers=None, client=None): + """ Create a new bucket, w/header customizations that should cause a failure + """ + bucket_name = get_new_bucket_name() + if client == None: + client = get_client() + + # pass in custom headers before PutObject call + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.CreateBucket', add_headers) + e = assert_raises(ClientError, client.create_bucket, Bucket=bucket_name) + + return e + + +def _remove_header_create_bucket(remove, client=None): + """ Create a new bucket, without a header + """ + bucket_name = get_new_bucket_name() + if client == None: + client = get_client() + + # remove custom headers before PutObject call + def remove_header(**kwargs): + if (remove in kwargs['params']['headers']): + del kwargs['params']['headers'][remove] + + client.meta.events.register('before-call.s3.CreateBucket', remove_header) + client.create_bucket(Bucket=bucket_name) + + return bucket_name + +def _remove_header_create_bad_bucket(remove, client=None): + """ Create a new bucket, without a header. This should cause a failure + """ + bucket_name = get_new_bucket_name() + if client == None: + client = get_client() + + # remove custom headers before PutObject call + def remove_header(**kwargs): + if (remove in kwargs['params']['headers']): + del kwargs['params']['headers'][remove] + + client.meta.events.register('before-call.s3.CreateBucket', remove_header) + e = assert_raises(ClientError, client.create_bucket, Bucket=bucket_name) + + return e + +# +# common tests +# + +@pytest.mark.auth_common +def test_object_create_bad_md5_invalid_short(): + e = _add_header_create_bad_object({'Content-MD5':'YWJyYWNhZGFicmE='}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidDigest' + +@pytest.mark.auth_common +def test_object_create_bad_md5_bad(): + e = _add_header_create_bad_object({'Content-MD5':'rL0Y20xC+Fzt72VPzMSk2A=='}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + +@pytest.mark.auth_common +def test_object_create_bad_md5_empty(): + e = _add_header_create_bad_object({'Content-MD5':''}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidDigest' + +@pytest.mark.auth_common +def test_object_create_bad_md5_none(): + bucket_name, key_name = _remove_header_create_object('Content-MD5') + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +def test_object_create_bad_expect_mismatch(): + bucket_name, key_name = _add_header_create_object({'Expect': 200}) + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +def test_object_create_bad_expect_empty(): + bucket_name, key_name = _add_header_create_object({'Expect': ''}) + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +def test_object_create_bad_expect_none(): + bucket_name, key_name = _remove_header_create_object('Expect') + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the content-length header +@pytest.mark.fails_on_rgw +def test_object_create_bad_contentlength_empty(): + e = _add_header_create_bad_object({'Content-Length':''}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.auth_common +@pytest.mark.fails_on_mod_proxy_fcgi +def test_object_create_bad_contentlength_negative(): + # to test Content-Length=-1, we have to prevent the checksum calculation + # from switching to STREAMING-UNSIGNED-PAYLOAD-TRAILER + config = botocore.config.Config(request_checksum_calculation = 'when_required') + client = get_client(config) + bucket_name = get_new_bucket() + key_name = 'foo' + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key_name, ContentLength=-1) + status = _get_status(e.response) + assert status == 400 + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the content-length header +@pytest.mark.fails_on_rgw +def test_object_create_bad_contentlength_none(): + remove = 'Content-Length' + e = _remove_header_create_bad_object('Content-Length') + status, error_code = _get_status_and_error_code(e.response) + assert status == 411 + assert error_code == 'MissingContentLength' + +@pytest.mark.auth_common +def test_object_create_bad_contenttype_invalid(): + bucket_name, key_name = _add_header_create_object({'Content-Type': 'text/plain'}) + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +def test_object_create_bad_contenttype_empty(): + client = get_client() + key_name = 'foo' + bucket_name = get_new_bucket() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar', ContentType='') + +@pytest.mark.auth_common +def test_object_create_bad_contenttype_none(): + bucket_name = get_new_bucket() + key_name = 'foo' + client = get_client() + # as long as ContentType isn't specified in put_object it isn't going into the request + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the authorization header +@pytest.mark.fails_on_rgw +def test_object_create_bad_authorization_empty(): + e = _add_header_create_bad_object({'Authorization': ''}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to pass both the 'Date' and 'X-Amz-Date' header during signing and not 'X-Amz-Date' before +@pytest.mark.fails_on_rgw +def test_object_create_date_and_amz_date(): + date = formatdate(usegmt=True) + bucket_name, key_name = _add_header_create_object({'Date': date, 'X-Amz-Date': date}) + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to pass both the 'Date' and 'X-Amz-Date' header during signing and not 'X-Amz-Date' before +@pytest.mark.fails_on_rgw +def test_object_create_amz_date_and_no_date(): + date = formatdate(usegmt=True) + bucket_name, key_name = _add_header_create_object({'Date': '', 'X-Amz-Date': date}) + client = get_client() + client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +# the teardown is really messed up here. check it out +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the authorization header +@pytest.mark.fails_on_rgw +def test_object_create_bad_authorization_none(): + e = _remove_header_create_bad_object('Authorization') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the content-length header +@pytest.mark.fails_on_rgw +def test_bucket_create_contentlength_none(): + remove = 'Content-Length' + _remove_header_create_bucket(remove) + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the content-length header +@pytest.mark.fails_on_rgw +def test_object_acl_create_contentlength_none(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + remove = 'Content-Length' + def remove_header(**kwargs): + if (remove in kwargs['params']['headers']): + del kwargs['params']['headers'][remove] + + client.meta.events.register('before-call.s3.PutObjectAcl', remove_header) + client.put_object_acl(Bucket=bucket_name, Key='foo', ACL='public-read') + +@pytest.mark.auth_common +def test_bucket_put_bad_canned_acl(): + bucket_name = get_new_bucket() + client = get_client() + + headers = {'x-amz-acl': 'public-ready'} + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.PutBucketAcl', add_headers) + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name, ACL='public-read') + status = _get_status(e.response) + assert status == 400 + +@pytest.mark.auth_common +def test_bucket_create_bad_expect_mismatch(): + bucket_name = get_new_bucket_name() + client = get_client() + + headers = {'Expect': 200} + add_headers = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.CreateBucket', add_headers) + client.create_bucket(Bucket=bucket_name) + +@pytest.mark.auth_common +def test_bucket_create_bad_expect_empty(): + headers = {'Expect': ''} + _add_header_create_bucket(headers) + +@pytest.mark.auth_common +# TODO: The request isn't even making it to the RGW past the frontend +# This test had 'fails_on_rgw' before the move to boto3 +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_contentlength_empty(): + headers = {'Content-Length': ''} + e = _add_header_create_bad_bucket(headers) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.auth_common +@pytest.mark.fails_on_mod_proxy_fcgi +def test_bucket_create_bad_contentlength_negative(): + headers = {'Content-Length': '-1'} + e = _add_header_create_bad_bucket(headers) + status = _get_status(e.response) + assert status == 400 + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the content-length header +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_contentlength_none(): + remove = 'Content-Length' + _remove_header_create_bucket(remove) + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to manipulate the authorization header +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_authorization_empty(): + headers = {'Authorization': ''} + e = _add_header_create_bad_bucket(headers) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_common +# TODO: remove 'fails_on_rgw' and once we have learned how to manipulate the authorization header +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_authorization_none(): + e = _remove_header_create_bad_bucket('Authorization') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_object_create_bad_md5_invalid_garbage_aws2(): + v2_client = get_v2_client() + headers = {'Content-MD5': 'AWS HAHAHA'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidDigest' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to manipulate the Content-Length header +@pytest.mark.fails_on_rgw +def test_object_create_bad_contentlength_mismatch_below_aws2(): + v2_client = get_v2_client() + content = 'bar' + length = len(content) - 1 + headers = {'Content-Length': str(length)} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to manipulate the authorization header +@pytest.mark.fails_on_rgw +def test_object_create_bad_authorization_incorrect_aws2(): + v2_client = get_v2_client() + headers = {'Authorization': 'AWS AKIAIGR7ZNNBHC5BKSUB:FWeDfwojDSdS2Ztmpfeubhd9isU='} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'InvalidDigest' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to manipulate the authorization header +@pytest.mark.fails_on_rgw +def test_object_create_bad_authorization_invalid_aws2(): + v2_client = get_v2_client() + headers = {'Authorization': 'AWS HAHAHA'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.auth_aws2 +def test_object_create_bad_ua_empty_aws2(): + v2_client = get_v2_client() + headers = {'User-Agent': ''} + bucket_name, key_name = _add_header_create_object(headers, v2_client) + v2_client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_aws2 +def test_object_create_bad_ua_none_aws2(): + v2_client = get_v2_client() + remove = 'User-Agent' + bucket_name, key_name = _remove_header_create_object(remove, v2_client) + v2_client.put_object(Bucket=bucket_name, Key=key_name, Body='bar') + +@pytest.mark.auth_aws2 +def test_object_create_bad_date_invalid_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Bad Date'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_object_create_bad_date_empty_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': ''} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the date header +@pytest.mark.fails_on_rgw +def test_object_create_bad_date_none_aws2(): + v2_client = get_v2_client() + remove = 'x-amz-date' + e = _remove_header_create_bad_object(remove, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_object_create_bad_date_before_today_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 2010 21:53:04 GMT'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'RequestTimeTooSkewed' + +@pytest.mark.auth_aws2 +def test_object_create_bad_date_before_epoch_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 1950 21:53:04 GMT'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_object_create_bad_date_after_end_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 9999 21:53:04 GMT'} + e = _add_header_create_bad_object(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'RequestTimeTooSkewed' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the date header +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_authorization_invalid_aws2(): + v2_client = get_v2_client() + headers = {'Authorization': 'AWS HAHAHA'} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.auth_aws2 +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_ua_empty_aws2(): + v2_client = get_v2_client() + headers = {'User-Agent': ''} + _add_header_create_bucket(headers, v2_client) + +@pytest.mark.auth_aws2 +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_ua_none_aws2(): + v2_client = get_v2_client() + remove = 'User-Agent' + _remove_header_create_bucket(remove, v2_client) + +@pytest.mark.auth_aws2 +def test_bucket_create_bad_date_invalid_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Bad Date'} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_bucket_create_bad_date_empty_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': ''} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +# TODO: remove 'fails_on_rgw' and once we have learned how to remove the date header +@pytest.mark.fails_on_rgw +def test_bucket_create_bad_date_none_aws2(): + v2_client = get_v2_client() + remove = 'x-amz-date' + e = _remove_header_create_bad_bucket(remove, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.auth_aws2 +def test_bucket_create_bad_date_before_today_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 2010 21:53:04 GMT'} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'RequestTimeTooSkewed' + +@pytest.mark.auth_aws2 +def test_bucket_create_bad_date_after_today_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 2030 21:53:04 GMT'} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'RequestTimeTooSkewed' + +@pytest.mark.auth_aws2 +def test_bucket_create_bad_date_before_epoch_aws2(): + v2_client = get_v2_client() + headers = {'x-amz-date': 'Tue, 07 Jul 1950 21:53:04 GMT'} + e = _add_header_create_bad_bucket(headers, v2_client) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_iam.py b/src/test/rgw/s3-tests/s3tests/functional/test_iam.py new file mode 100644 index 00000000000..d16015b9554 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_iam.py @@ -0,0 +1,3007 @@ +import json +import datetime +import time + +from botocore.exceptions import ClientError +import pytest + +from .utils import assert_raises +from .test_s3 import _multipart_upload +from . import ( + configfile, + setup_teardown, + get_alt_client, + get_iam_client, + get_iam_root_client, + get_iam_root_account_id, + get_iam_alt_root_client, + get_iam_alt_root_user_id, + get_iam_alt_root_email, + get_iam_alt_root_account_id, + make_iam_name, + get_iam_path_prefix, + get_new_bucket, + get_new_bucket_name, + get_iam_s3client, + get_alt_iam_client, + get_alt_user_id, + get_sts_client, +) +from .utils import _get_status, _get_status_and_error_code +from .iam import iam_root, iam_alt_root + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_put_user_policy(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_put_user_policy_invalid_user(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy', UserName="some-non-existing-user-id") + status = _get_status(e.response) + assert status == 404 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_put_user_policy_parameter_limit(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "*", + "Resource": "*"}] * 1000 + } + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy' * 10, UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 400 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_rgw +def test_put_user_policy_invalid_element(): + client = get_iam_client() + + # With Version other than 2012-10-17 + policy_document = json.dumps( + {"Version": "2010-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "*", + "Resource": "*"}] + } + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 400 + + # With no Statement + policy_document = json.dumps( + { + "Version": "2012-10-17", + } + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 400 + + # with same Sid for 2 statements + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": [ + {"Sid": "98AB54CF", + "Effect": "Allow", + "Action": "*", + "Resource": "*"}, + {"Sid": "98AB54CF", + "Effect": "Allow", + "Action": "*", + "Resource": "*"}] + } + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 400 + + # with Principal + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "*", + "Resource": "*", + "Principal": "arn:aws:iam:::username"}] + } + ) + e = assert_raises(ClientError, client.put_user_policy, PolicyDocument=policy_document, + PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 400 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_put_existing_user_policy(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"} + } + ) + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + client.delete_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_list_user_policy(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"} + } + ) + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.list_user_policies(UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + client.delete_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_list_user_policy_invalid_user(): + client = get_iam_client() + e = assert_raises(ClientError, client.list_user_policies, UserName="some-non-existing-user-id") + status = _get_status(e.response) + assert status == 404 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_get_user_policy(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.delete_user_policy(PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_get_user_policy_invalid_user(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + e = assert_raises(ClientError, client.get_user_policy, PolicyName='AllAccessPolicy', + UserName="some-non-existing-user-id") + status = _get_status(e.response) + assert status == 404 + client.delete_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_rgw +def test_get_user_policy_invalid_policy_name(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + e = assert_raises(ClientError, client.get_user_policy, PolicyName='non-existing-policy-name', + UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 404 + client.delete_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_rgw +def test_get_deleted_user_policy(): + client = get_iam_client() + + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + client.put_user_policy(PolicyDocument=policy_document, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + client.delete_user_policy(PolicyName='AllAccessPolicy', UserName=get_alt_user_id()) + e = assert_raises(ClientError, client.get_user_policy, PolicyName='AllAccessPolicy', + UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 404 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_get_user_policy_from_multiple_policies(): + client = get_iam_client() + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy1', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy2', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_user_policy(PolicyName='AllowAccessPolicy2', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.delete_user_policy(PolicyName='AllowAccessPolicy1', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy2', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_delete_user_policy(): + client = get_iam_client() + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_delete_user_policy_invalid_user(): + client = get_iam_client() + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + e = assert_raises(ClientError, client.delete_user_policy, PolicyName='AllAccessPolicy', + UserName="some-non-existing-user-id") + status = _get_status(e.response) + assert status == 404 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_delete_user_policy_invalid_policy_name(): + client = get_iam_client() + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + e = assert_raises(ClientError, client.delete_user_policy, PolicyName='non-existing-policy-name', + UserName=get_alt_user_id()) + status = _get_status(e.response) + assert status == 404 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_delete_user_policy_from_multiple_policies(): + client = get_iam_client() + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "*", + "Resource": "*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy1', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy2', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy3', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy1', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy2', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_user_policy(PolicyName='AllowAccessPolicy3', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.delete_user_policy(PolicyName='AllowAccessPolicy3', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_allow_bucket_actions_in_user_policy(): + client = get_iam_client() + s3_client_alt = get_alt_client() + + s3_client_iam = get_iam_s3client() + bucket = get_new_bucket(client=s3_client_iam) + s3_client_iam.put_object(Bucket=bucket, Key='foo', Body='bar') + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": ["s3:ListBucket", "s3:DeleteBucket"], + "Resource": f"arn:aws:s3:::{bucket}"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = s3_client_alt.list_objects(Bucket=bucket) + object_found = False + for object_received in response['Contents']: + if "foo" == object_received['Key']: + object_found = True + break + if not object_found: + raise AssertionError("Object is not listed") + + response = s3_client_iam.delete_object(Bucket=bucket, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = s3_client_alt.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = s3_client_iam.list_buckets() + for bucket in response['Buckets']: + if bucket == bucket['Name']: + raise AssertionError("deleted bucket is getting listed") + + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_deny_bucket_actions_in_user_policy(): + client = get_iam_client() + s3_client = get_alt_client() + bucket = get_new_bucket(client=s3_client) + + policy_document_deny = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Deny", + "Action": ["s3:ListAllMyBuckets", "s3:DeleteBucket"], + "Resource": "arn:aws:s3:::*"}} + ) + + response = client.put_user_policy(PolicyDocument=policy_document_deny, + PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + e = assert_raises(ClientError, s3_client.list_buckets) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + e = assert_raises(ClientError, s3_client.delete_bucket, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = s3_client.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_allow_object_actions_in_user_policy(): + client = get_iam_client() + s3_client_alt = get_alt_client() + s3_client_iam = get_iam_s3client() + bucket = get_new_bucket(client=s3_client_iam) + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], + "Resource": f"arn:aws:s3:::{bucket}/*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_alt.put_object(Bucket=bucket, Key='foo', Body='bar') + response = s3_client_alt.get_object(Bucket=bucket, Key='foo') + body = response['Body'].read() + if type(body) is bytes: + body = body.decode() + assert body == "bar" + response = s3_client_alt.delete_object(Bucket=bucket, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + e = assert_raises(ClientError, s3_client_iam.get_object, Bucket=bucket, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + response = s3_client_iam.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_deny_object_actions_in_user_policy(): + client = get_iam_client() + s3_client_alt = get_alt_client() + bucket = get_new_bucket(client=s3_client_alt) + s3_client_alt.put_object(Bucket=bucket, Key='foo', Body='bar') + + policy_document_deny = json.dumps( + {"Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], + "Resource": f"arn:aws:s3:::{bucket}/*"}, { + "Effect": "Allow", + "Action": ["s3:DeleteBucket"], + "Resource": f"arn:aws:s3:::{bucket}"}]} + ) + client.put_user_policy(PolicyDocument=policy_document_deny, PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + + e = assert_raises(ClientError, s3_client_alt.put_object, Bucket=bucket, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + e = assert_raises(ClientError, s3_client_alt.get_object, Bucket=bucket, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + e = assert_raises(ClientError, s3_client_alt.delete_object, Bucket=bucket, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_allow_multipart_actions_in_user_policy(): + client = get_iam_client() + s3_client_alt = get_alt_client() + s3_client_iam = get_iam_s3client() + bucket = get_new_bucket(client=s3_client_iam) + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": ["s3:ListBucketMultipartUploads", "s3:AbortMultipartUpload"], + "Resource": "arn:aws:s3:::*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document_allow, + PolicyName='AllowAccessPolicy', UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + key = "mymultipart" + mb = 1024 * 1024 + + (upload_id, _, _) = _multipart_upload(client=s3_client_iam, bucket_name=bucket, key=key, + size=5 * mb) + response = s3_client_alt.list_multipart_uploads(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = s3_client_alt.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = s3_client_iam.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_deny_multipart_actions_in_user_policy(): + client = get_iam_client() + s3_client = get_alt_client() + bucket = get_new_bucket(client=s3_client) + + policy_document_deny = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Deny", + "Action": ["s3:ListBucketMultipartUploads", "s3:AbortMultipartUpload"], + "Resource": "arn:aws:s3:::*"}} + ) + response = client.put_user_policy(PolicyDocument=policy_document_deny, + PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + key = "mymultipart" + mb = 1024 * 1024 + + (upload_id, _, _) = _multipart_upload(client=s3_client, bucket_name=bucket, key=key, + size=5 * mb) + + e = assert_raises(ClientError, s3_client.list_multipart_uploads, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + e = assert_raises(ClientError, s3_client.abort_multipart_upload, Bucket=bucket, + Key=key, UploadId=upload_id) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = s3_client.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_allow_tagging_actions_in_user_policy(): + client = get_iam_client() + s3_client_alt = get_alt_client() + s3_client_iam = get_iam_s3client() + bucket = get_new_bucket(client=s3_client_iam) + + policy_document_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": ["s3:PutBucketTagging", "s3:GetBucketTagging", + "s3:PutObjectTagging", "s3:GetObjectTagging"], + "Resource": f"arn:aws:s3:::*"}} + ) + client.put_user_policy(PolicyDocument=policy_document_allow, PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + tags = {'TagSet': [{'Key': 'Hello', 'Value': 'World'}, ]} + + response = s3_client_alt.put_bucket_tagging(Bucket=bucket, Tagging=tags) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = s3_client_alt.get_bucket_tagging(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['TagSet'][0]['Key'] == 'Hello' + assert response['TagSet'][0]['Value'] == 'World' + + obj_key = 'obj' + response = s3_client_iam.put_object(Bucket=bucket, Key=obj_key, Body='obj_body') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = s3_client_alt.put_object_tagging(Bucket=bucket, Key=obj_key, Tagging=tags) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = s3_client_alt.get_object_tagging(Bucket=bucket, Key=obj_key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['TagSet'] == tags['TagSet'] + + response = s3_client_iam.delete_object(Bucket=bucket, Key=obj_key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = s3_client_iam.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_deny_tagging_actions_in_user_policy(): + client = get_iam_client() + s3_client = get_alt_client() + bucket = get_new_bucket(client=s3_client) + + policy_document_deny = json.dumps( + {"Version": "2012-10-17", + "Statement": { + "Effect": "Deny", + "Action": ["s3:PutBucketTagging", "s3:GetBucketTagging", + "s3:PutObjectTagging", "s3:DeleteObjectTagging"], + "Resource": "arn:aws:s3:::*"}} + ) + client.put_user_policy(PolicyDocument=policy_document_deny, PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + tags = {'TagSet': [{'Key': 'Hello', 'Value': 'World'}, ]} + + e = assert_raises(ClientError, s3_client.put_bucket_tagging, Bucket=bucket, Tagging=tags) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + e = assert_raises(ClientError, s3_client.get_bucket_tagging, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + obj_key = 'obj' + response = s3_client.put_object(Bucket=bucket, Key=obj_key, Body='obj_body') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + e = assert_raises(ClientError, s3_client.put_object_tagging, Bucket=bucket, Key=obj_key, + Tagging=tags) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + e = assert_raises(ClientError, s3_client.delete_object_tagging, Bucket=bucket, Key=obj_key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = s3_client.delete_object(Bucket=bucket, Key=obj_key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = s3_client.delete_bucket(Bucket=bucket) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_verify_conflicting_user_policy_statements(): + s3client = get_alt_client() + bucket = get_new_bucket(client=s3client) + policy_document = json.dumps( + {"Version": "2012-10-17", + "Statement": [ + {"Sid": "98AB54CG", + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": f"arn:aws:s3:::{bucket}"}, + {"Sid": "98AB54CA", + "Effect": "Deny", + "Action": "s3:ListBucket", + "Resource": f"arn:aws:s3:::{bucket}"} + ]} + ) + client = get_iam_client() + response = client.put_user_policy(PolicyDocument=policy_document, PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + e = assert_raises(ClientError, s3client.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +@pytest.mark.fails_on_dbstore +def test_verify_conflicting_user_policies(): + s3client = get_alt_client() + bucket = get_new_bucket(client=s3client) + policy_allow = json.dumps( + {"Version": "2012-10-17", + "Statement": {"Sid": "98AB54CG", + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": f"arn:aws:s3:::{bucket}"}} + ) + policy_deny = json.dumps( + {"Version": "2012-10-17", + "Statement": {"Sid": "98AB54CGZ", + "Effect": "Deny", + "Action": "s3:ListBucket", + "Resource": f"arn:aws:s3:::{bucket}"}} + ) + client = get_iam_client() + response = client.put_user_policy(PolicyDocument=policy_allow, PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_user_policy(PolicyDocument=policy_deny, PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + e = assert_raises(ClientError, s3client.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + response = client.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.delete_user_policy(PolicyName='DenyAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.user_policy +@pytest.mark.iam_tenant +def test_verify_allow_iam_actions(): + policy1 = json.dumps( + {"Version": "2012-10-17", + "Statement": {"Sid": "98AB54CGA", + "Effect": "Allow", + "Action": ["iam:PutUserPolicy", "iam:GetUserPolicy", + "iam:ListUserPolicies", "iam:DeleteUserPolicy"], + "Resource": f"arn:aws:iam:::user/{get_alt_user_id()}"}} + ) + client1 = get_iam_client() + iam_client_alt = get_alt_iam_client() + + response = client1.put_user_policy(PolicyDocument=policy1, PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = iam_client_alt.get_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = iam_client_alt.list_user_policies(UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = iam_client_alt.delete_user_policy(PolicyName='AllowAccessPolicy', + UserName=get_alt_user_id()) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +# IAM User apis +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_create(iam_root): + path = get_iam_path_prefix() + name1 = make_iam_name('U1') + response = iam_root.create_user(UserName=name1, Path=path) + user = response['User'] + assert user['Path'] == path + assert user['UserName'] == name1 + assert len(user['UserId']) + assert user['Arn'].startswith('arn:aws:iam:') + assert user['Arn'].endswith(f':user{path}{name1}') + assert user['CreateDate'] > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + + path2 = get_iam_path_prefix() + 'foo/' + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_user(UserName=name1, Path=path2) + + name2 = make_iam_name('U2') + response = iam_root.create_user(UserName=name2, Path=path2) + user = response['User'] + assert user['Path'] == path2 + assert user['UserName'] == name2 + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_case_insensitive_name(iam_root): + path = get_iam_path_prefix() + name_upper = make_iam_name('U1') + name_lower = make_iam_name('u1') + response = iam_root.create_user(UserName=name_upper, Path=path) + user = response['User'] + + # name is case-insensitive, so 'u1' should also conflict + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_user(UserName=name_lower) + + # search for 'u1' should return the same 'U1' user + response = iam_root.get_user(UserName=name_lower) + assert user == response['User'] + + # delete for 'u1' should delete the same 'U1' user + iam_root.delete_user(UserName=name_lower) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_user(UserName=name_lower) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_delete(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('U1') + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_user(UserName=name) + + response = iam_root.create_user(UserName=name, Path=path) + uid = response['User']['UserId'] + create_date = response['User']['CreateDate'] + + iam_root.delete_user(UserName=name) + + response = iam_root.create_user(UserName=name, Path=path) + assert uid != response['User']['UserId'] + assert create_date <= response['User']['CreateDate'] + +def user_list_names(client, **kwargs): + p = client.get_paginator('list_users') + usernames = [] + for response in p.paginate(**kwargs): + usernames += [u['UserName'] for u in response['Users']] + return usernames + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_list(iam_root): + path = get_iam_path_prefix() + response = iam_root.list_users(PathPrefix=path) + assert len(response['Users']) == 0 + assert response['IsTruncated'] == False + + name1 = make_iam_name('aa') + name2 = make_iam_name('Ab') + name3 = make_iam_name('ac') + name4 = make_iam_name('Ad') + + # sort order is independent of CreateDate, Path, and UserName capitalization + iam_root.create_user(UserName=name4, Path=path+'w/') + iam_root.create_user(UserName=name3, Path=path+'x/') + iam_root.create_user(UserName=name2, Path=path+'y/') + iam_root.create_user(UserName=name1, Path=path+'z/') + + assert [name1, name2, name3, name4] == \ + user_list_names(iam_root, PathPrefix=path) + assert [name1, name2, name3, name4] == \ + user_list_names(iam_root, PathPrefix=path, PaginationConfig={'PageSize': 1}) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_list_path_prefix(iam_root): + path = get_iam_path_prefix() + response = iam_root.list_users(PathPrefix=path) + assert len(response['Users']) == 0 + assert response['IsTruncated'] == False + + name1 = make_iam_name('a') + name2 = make_iam_name('b') + name3 = make_iam_name('c') + name4 = make_iam_name('d') + + iam_root.create_user(UserName=name1, Path=path) + iam_root.create_user(UserName=name2, Path=path) + iam_root.create_user(UserName=name3, Path=path+'a/') + iam_root.create_user(UserName=name4, Path=path+'a/x/') + + assert [name1, name2, name3, name4] == \ + user_list_names(iam_root, PathPrefix=path) + assert [name1, name2, name3, name4] == \ + user_list_names(iam_root, PathPrefix=path, + PaginationConfig={'PageSize': 1}) + assert [name3, name4] == \ + user_list_names(iam_root, PathPrefix=path+'a') + assert [name3, name4] == \ + user_list_names(iam_root, PathPrefix=path+'a', + PaginationConfig={'PageSize': 1}) + assert [name4] == \ + user_list_names(iam_root, PathPrefix=path+'a/x') + assert [name4] == \ + user_list_names(iam_root, PathPrefix=path+'a/x', + PaginationConfig={'PageSize': 1}) + assert [] == user_list_names(iam_root, PathPrefix=path+'a/x/d') + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_update_name(iam_root): + path = get_iam_path_prefix() + name1 = make_iam_name('a') + new_name1 = make_iam_name('z') + name2 = make_iam_name('b') + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.update_user(UserName=name1, NewUserName=new_name1) + + iam_root.create_user(UserName=name1, Path=path) + iam_root.create_user(UserName=name2, Path=path+'m/') + assert [name1, name2] == user_list_names(iam_root, PathPrefix=path) + + response = iam_root.get_user(UserName=name1) + assert name1 == response['User']['UserName'] + uid = response['User']['UserId'] + + iam_root.update_user(UserName=name1, NewUserName=new_name1) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_user(UserName=name1) + + response = iam_root.get_user(UserName=new_name1) + assert new_name1 == response['User']['UserName'] + assert uid == response['User']['UserId'] + assert response['User']['Arn'].endswith(f':user{path}{new_name1}') + + assert [name2, new_name1] == user_list_names(iam_root, PathPrefix=path) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_update_path(iam_root): + path = get_iam_path_prefix() + name1 = make_iam_name('a') + name2 = make_iam_name('b') + iam_root.create_user(UserName=name1, Path=path) + iam_root.create_user(UserName=name2, Path=path+'m/') + assert [name1, name2] == user_list_names(iam_root, PathPrefix=path) + + response = iam_root.get_user(UserName=name1) + assert name1 == response['User']['UserName'] + assert path == response['User']['Path'] + uid = response['User']['UserId'] + + iam_root.update_user(UserName=name1, NewPath=path+'z/') + + response = iam_root.get_user(UserName=name1) + assert name1 == response['User']['UserName'] + assert f'{path}z/' == response['User']['Path'] + assert uid == response['User']['UserId'] + assert response['User']['Arn'].endswith(f':user{path}z/{name1}') + + assert [name1, name2] == user_list_names(iam_root, PathPrefix=path) + + +# IAM AccessKey apis +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_access_key_create(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('a') + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.create_access_key(UserName=name) + + iam_root.create_user(UserName=name, Path=path) + + response = iam_root.create_access_key(UserName=name) + key = response['AccessKey'] + assert name == key['UserName'] + assert len(key['AccessKeyId']) + assert len(key['SecretAccessKey']) + assert 'Active' == key['Status'] + assert key['CreateDate'] > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_current_user_access_key_create(iam_root): + # omit the UserName argument to operate on the current authenticated + # user (assumed to be an account root user) + + response = iam_root.create_access_key() + key = response['AccessKey'] + keyid = key['AccessKeyId'] + assert len(keyid) + try: + assert len(key['SecretAccessKey']) + assert 'Active' == key['Status'] + assert key['CreateDate'] > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + finally: + # iam_root doesn't see the account root user, so clean up + # this key manually + iam_root.delete_access_key(AccessKeyId=keyid) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_access_key_update(iam_root): + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.update_access_key(UserName='nosuchuser', AccessKeyId='abcdefghijklmnopqrstu', Status='Active') + + path = get_iam_path_prefix() + name = make_iam_name('a') + iam_root.create_user(UserName=name, Path=path) + + response = iam_root.create_access_key(UserName=name) + key = response['AccessKey'] + keyid = key['AccessKeyId'] + create_date = key['CreateDate'] + assert create_date > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.update_access_key(UserName=name, AccessKeyId='abcdefghijklmnopqrstu', Status='Active') + + iam_root.update_access_key(UserName=name, AccessKeyId=keyid, Status='Active') + iam_root.update_access_key(UserName=name, AccessKeyId=keyid, Status='Inactive') + + response = iam_root.list_access_keys(UserName=name) + keys = response['AccessKeyMetadata'] + assert 1 == len(keys) + key = keys[0] + assert name == key['UserName'] + assert keyid == key['AccessKeyId'] + assert 'Inactive' == key['Status'] + assert create_date == key['CreateDate'] # CreateDate unchanged by update_access_key() + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_current_user_access_key_update(iam_root): + # omit the UserName argument to operate on the current authenticated + # user (assumed to be an account root user) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.update_access_key(AccessKeyId='abcdefghijklmnopqrstu', Status='Active') + + response = iam_root.create_access_key() + key = response['AccessKey'] + keyid = key['AccessKeyId'] + assert len(keyid) + try: + iam_root.update_access_key(AccessKeyId=keyid, Status='Active') + iam_root.update_access_key(AccessKeyId=keyid, Status='Inactive') + + # find the access key id we created + p = iam_root.get_paginator('list_access_keys') + for response in p.paginate(): + for key in response['AccessKeyMetadata']: + if keyid == key['AccessKeyId']: + assert 'Inactive' == key['Status'] + return + assert False, f'AccessKeyId={keyid} not found in list_access_keys()' + + finally: + # iam_root doesn't see the account root user, so clean up + # this key manually + iam_root.delete_access_key(AccessKeyId=keyid) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_access_key_delete(iam_root): + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_access_key(UserName='nosuchuser', AccessKeyId='abcdefghijklmnopqrstu') + + path = get_iam_path_prefix() + name = make_iam_name('a') + iam_root.create_user(UserName=name, Path=path) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_access_key(UserName=name, AccessKeyId='abcdefghijklmnopqrstu') + + response = iam_root.create_access_key(UserName=name) + keyid = response['AccessKey']['AccessKeyId'] + + iam_root.delete_access_key(UserName=name, AccessKeyId=keyid) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_access_key(UserName=name, AccessKeyId=keyid) + + response = iam_root.list_access_keys(UserName=name) + keys = response['AccessKeyMetadata'] + assert 0 == len(keys) + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_current_user_access_key_delete(iam_root): + # omit the UserName argument to operate on the current authenticated + # user (assumed to be an account root user) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_access_key(AccessKeyId='abcdefghijklmnopqrstu') + + response = iam_root.create_access_key() + keyid = response['AccessKey']['AccessKeyId'] + + iam_root.delete_access_key(AccessKeyId=keyid) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_access_key(AccessKeyId=keyid) + + # make sure list_access_keys() doesn't return the access key id we deleted + p = iam_root.get_paginator('list_access_keys') + for response in p.paginate(): + for key in response['AccessKeyMetadata']: + assert keyid != key['AccessKeyId'] + +def user_list_key_ids(client, **kwargs): + p = client.get_paginator('list_access_keys') + ids = [] + for response in p.paginate(**kwargs): + ids += [k['AccessKeyId'] for k in response['AccessKeyMetadata']] + return ids + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_access_key_list(iam_root): + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.list_access_keys(UserName='nosuchuser') + + path = get_iam_path_prefix() + name = make_iam_name('a') + iam_root.create_user(UserName=name, Path=path) + + assert [] == user_list_key_ids(iam_root, UserName=name) + assert [] == user_list_key_ids(iam_root, UserName=name, PaginationConfig={'PageSize': 1}) + + id1 = iam_root.create_access_key(UserName=name)['AccessKey']['AccessKeyId'] + + assert [id1] == user_list_key_ids(iam_root, UserName=name) + assert [id1] == user_list_key_ids(iam_root, UserName=name, PaginationConfig={'PageSize': 1}) + + id2 = iam_root.create_access_key(UserName=name)['AccessKey']['AccessKeyId'] + # AccessKeysPerUser=2 is the default quota in aws + + keys = sorted([id1, id2]) + assert keys == sorted(user_list_key_ids(iam_root, UserName=name)) + assert keys == sorted(user_list_key_ids(iam_root, UserName=name, PaginationConfig={'PageSize': 1})) + +def retry_on(code, tries, func, *args, **kwargs): + for i in range(tries): + try: + return func(*args, **kwargs) + except ClientError as e: + err = e.response['Error']['Code'] + if i + 1 < tries and err in code: + print(f'Got {err}, retrying in {i}s..') + time.sleep(i) + continue + raise + + +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_account_user_bucket_policy_allow(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + response = iam_root.create_user(UserName=name, Path=path) + user_arn = response['User']['Arn'] + assert user_arn.startswith('arn:aws:iam:') + assert user_arn.endswith(f':user{path}{name}') + + key = iam_root.create_access_key(UserName=name)['AccessKey'] + client = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # create a bucket with the root user + roots3 = get_iam_root_client(service_name='s3') + bucket = get_new_bucket(roots3) + try: + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, client.list_objects, Bucket=bucket) + # expect AccessDenied because no identity policy allows s3 actions + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket policy that allows s3:ListBucket for the iam user's arn + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': user_arn}, + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket}' + }] + }) + roots3.put_bucket_policy(Bucket=bucket, Policy=policy) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, client.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + + +# IAM UserPolicy apis +@pytest.mark.user_policy +@pytest.mark.iam_account +def test_account_user_policy(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + policy_name = 'List' + bucket_name = get_new_bucket_name() + policy1 = json.dumps({'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Deny', + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket_name}'}]}) + policy2 = json.dumps({'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Allow', + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket_name}'}]}) + + # Get/Put/Delete fail on nonexistent UserName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_user_policy(UserName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_user_policy(UserName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.put_user_policy(UserName=name, PolicyName=policy_name, PolicyDocument=policy1) + + iam_root.create_user(UserName=name, Path=path) + + # Get/Delete fail on nonexistent PolicyName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_user_policy(UserName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_user_policy(UserName=name, PolicyName=policy_name) + + iam_root.put_user_policy(UserName=name, PolicyName=policy_name, PolicyDocument=policy1) + + response = iam_root.get_user_policy(UserName=name, PolicyName=policy_name) + assert policy1 == json.dumps(response['PolicyDocument']) + response = iam_root.list_user_policies(UserName=name) + assert [policy_name] == response['PolicyNames'] + + iam_root.put_user_policy(UserName=name, PolicyName=policy_name, PolicyDocument=policy2) + + response = iam_root.get_user_policy(UserName=name, PolicyName=policy_name) + assert policy2 == json.dumps(response['PolicyDocument']) + response = iam_root.list_user_policies(UserName=name) + assert [policy_name] == response['PolicyNames'] + + iam_root.delete_user_policy(UserName=name, PolicyName=policy_name) + + # Get/Delete fail after Delete + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_user_policy(UserName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_user_policy(UserName=name, PolicyName=policy_name) + + response = iam_root.list_user_policies(UserName=name) + assert [] == response['PolicyNames'] + +@pytest.mark.user_policy +@pytest.mark.iam_account +def test_account_user_policy_managed(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + policy1 = 'arn:aws:iam::aws:policy/AmazonS3FullAccess' + policy2 = 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' + + # Attach/Detach/List fail on nonexistent UserName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.attach_user_policy(UserName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_user_policy(UserName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.list_attached_user_policies(UserName=name) + + iam_root.create_user(UserName=name, Path=path) + + # Detach fails on unattached PolicyArn + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_user_policy(UserName=name, PolicyArn=policy1) + + iam_root.attach_user_policy(UserName=name, PolicyArn=policy1) + iam_root.attach_user_policy(UserName=name, PolicyArn=policy1) + + response = iam_root.list_attached_user_policies(UserName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + iam_root.attach_user_policy(UserName=name, PolicyArn=policy2) + + response = iam_root.list_attached_user_policies(UserName=name) + policies = response['AttachedPolicies'] + assert len(policies) == 2 + names = [p['PolicyName'] for p in policies] + arns = [p['PolicyArn'] for p in policies] + assert 'AmazonS3FullAccess' in names + assert policy1 in arns + assert 'AmazonS3ReadOnlyAccess' in names + assert policy2 in arns + + iam_root.detach_user_policy(UserName=name, PolicyArn=policy2) + + # Detach fails after Detach + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_user_policy(UserName=name, PolicyArn=policy2) + + response = iam_root.list_attached_user_policies(UserName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + # DeleteUser fails while policies are still attached + with pytest.raises(iam_root.exceptions.DeleteConflictException): + iam_root.delete_user(UserName=name) + +@pytest.mark.user_policy +@pytest.mark.iam_account +def test_account_user_policy_allow(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + bucket_name = get_new_bucket_name() + iam_root.create_user(UserName=name, Path=path) + + key = iam_root.create_access_key(UserName=name)['AccessKey'] + client = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, client.list_buckets) + # expect AccessDenied because no identity policy allows s3 actions + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a user policy that allows s3 actions + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + }) + policy_name = 'AllowStar' + iam_root.put_user_policy(UserName=name, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, client.list_buckets) + + +def group_list_names(client, **kwargs): + p = client.get_paginator('list_groups') + names = [] + for response in p.paginate(**kwargs): + names += [u['GroupName'] for u in response['Groups']] + return names + +# IAM Group apis +@pytest.mark.group +@pytest.mark.iam_account +def test_account_group_create(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('G1') + + assert [] == group_list_names(iam_root, PathPrefix=path) + + response = iam_root.create_group(GroupName=name, Path=path) + group = response['Group'] + assert path == group['Path'] + assert name == group['GroupName'] + assert len(group['GroupId']) + arn = group['Arn'] + assert arn.startswith('arn:aws:iam:') + assert arn.endswith(f':group{path}{name}') + + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_group(GroupName=name) + + response = iam_root.get_group(GroupName=name) + assert group == response['Group'] + + assert [name] == group_list_names(iam_root, PathPrefix=path) + + iam_root.delete_group(GroupName=name) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_group(GroupName=name) + + assert [] == group_list_names(iam_root, PathPrefix=path) + +@pytest.mark.iam_account +@pytest.mark.group +def test_account_group_case_insensitive_name(iam_root): + path = get_iam_path_prefix() + name_upper = make_iam_name('G1') + name_lower = make_iam_name('g1') + response = iam_root.create_group(GroupName=name_upper, Path=path) + group = response['Group'] + + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_group(GroupName=name_lower) + + response = iam_root.get_group(GroupName=name_lower) + assert group == response['Group'] + + iam_root.delete_group(GroupName=name_lower) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_group(GroupName=name_upper) + +@pytest.mark.iam_account +@pytest.mark.group +def test_account_group_list(iam_root): + path = get_iam_path_prefix() + response = iam_root.list_groups(PathPrefix=path) + assert len(response['Groups']) == 0 + assert response['IsTruncated'] == False + + name1 = make_iam_name('aa') + name2 = make_iam_name('Ab') + name3 = make_iam_name('ac') + name4 = make_iam_name('Ad') + + # sort order is independent of Path and GroupName capitalization + iam_root.create_group(GroupName=name4, Path=path+'w/') + iam_root.create_group(GroupName=name3, Path=path+'x/') + iam_root.create_group(GroupName=name2, Path=path+'y/') + iam_root.create_group(GroupName=name1, Path=path+'z/') + + assert [name1, name2, name3, name4] == \ + group_list_names(iam_root, PathPrefix=path) + assert [name1, name2, name3, name4] == \ + group_list_names(iam_root, PathPrefix=path, PaginationConfig={'PageSize': 1}) + +@pytest.mark.group +@pytest.mark.iam_account +def test_account_group_update(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('G1') + response = iam_root.create_group(GroupName=name, Path=path) + group_id = response['Group']['GroupId'] + + username = make_iam_name('U1') + iam_root.create_user(UserName=username, Path=path) + + iam_root.add_user_to_group(GroupName=name, UserName=username) + + response = iam_root.list_groups_for_user(UserName=username) + groups = response['Groups'] + assert len(groups) == 1 + assert path == groups[0]['Path'] + assert name == groups[0]['GroupName'] + assert group_id == groups[0]['GroupId'] + + new_path = path + 'new/' + new_name = make_iam_name('NG1') + iam_root.update_group(GroupName=name, NewPath=new_path, NewGroupName=new_name) + + response = iam_root.get_group(GroupName=new_name) + group = response['Group'] + assert new_path == group['Path'] + assert new_name == group['GroupName'] + assert group_id == group['GroupId'] + arn = group['Arn'] + assert arn.startswith('arn:aws:iam:') + assert arn.endswith(f':group{new_path}{new_name}') + users = response['Users'] + assert len(users) == 1 + assert username == users[0]['UserName'] + + response = iam_root.list_groups_for_user(UserName=username) + groups = response['Groups'] + assert len(groups) == 1 + assert new_path == groups[0]['Path'] + assert new_name == groups[0]['GroupName'] + assert group_id == groups[0]['GroupId'] + + +# IAM GroupPolicy apis +@pytest.mark.group_policy +@pytest.mark.iam_account +def test_account_inline_group_policy(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + policy_name = 'List' + bucket_name = get_new_bucket_name() + policy1 = json.dumps({'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Deny', + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket_name}'}]}) + policy2 = json.dumps({'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Allow', + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket_name}'}]}) + + # Get/Put/Delete fail on nonexistent GroupName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_group_policy(GroupName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_group_policy(GroupName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.put_group_policy(GroupName=name, PolicyName=policy_name, PolicyDocument=policy1) + + iam_root.create_group(GroupName=name, Path=path) + + # Get/Delete fail on nonexistent PolicyName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_group_policy(GroupName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_group_policy(GroupName=name, PolicyName=policy_name) + + iam_root.put_group_policy(GroupName=name, PolicyName=policy_name, PolicyDocument=policy1) + + response = iam_root.get_group_policy(GroupName=name, PolicyName=policy_name) + assert policy1 == json.dumps(response['PolicyDocument']) + response = iam_root.list_group_policies(GroupName=name) + assert [policy_name] == response['PolicyNames'] + + iam_root.put_group_policy(GroupName=name, PolicyName=policy_name, PolicyDocument=policy2) + + response = iam_root.get_group_policy(GroupName=name, PolicyName=policy_name) + assert policy2 == json.dumps(response['PolicyDocument']) + response = iam_root.list_group_policies(GroupName=name) + assert [policy_name] == response['PolicyNames'] + + # DeleteGroup fails while policies are still attached + with pytest.raises(iam_root.exceptions.DeleteConflictException): + iam_root.delete_group(GroupName=name) + + iam_root.delete_group_policy(GroupName=name, PolicyName=policy_name) + + # Get/Delete fail after Delete + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_group_policy(GroupName=name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_group_policy(GroupName=name, PolicyName=policy_name) + + response = iam_root.list_group_policies(GroupName=name) + assert [] == response['PolicyNames'] + +@pytest.mark.group_policy +@pytest.mark.iam_account +def test_account_managed_group_policy(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + policy1 = 'arn:aws:iam::aws:policy/AmazonS3FullAccess' + policy2 = 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' + + # Attach/Detach/List fail on nonexistent GroupName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.attach_group_policy(GroupName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_group_policy(GroupName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.list_attached_group_policies(GroupName=name) + + iam_root.create_group(GroupName=name, Path=path) + + # Detach fails on unattached PolicyArn + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_group_policy(GroupName=name, PolicyArn=policy1) + + iam_root.attach_group_policy(GroupName=name, PolicyArn=policy1) + iam_root.attach_group_policy(GroupName=name, PolicyArn=policy1) + + response = iam_root.list_attached_group_policies(GroupName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + iam_root.attach_group_policy(GroupName=name, PolicyArn=policy2) + + response = iam_root.list_attached_group_policies(GroupName=name) + policies = response['AttachedPolicies'] + assert len(policies) == 2 + names = [p['PolicyName'] for p in policies] + arns = [p['PolicyArn'] for p in policies] + assert 'AmazonS3FullAccess' in names + assert policy1 in arns + assert 'AmazonS3ReadOnlyAccess' in names + assert policy2 in arns + + iam_root.detach_group_policy(GroupName=name, PolicyArn=policy2) + + # Detach fails after Detach + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_group_policy(GroupName=name, PolicyArn=policy2) + + response = iam_root.list_attached_group_policies(GroupName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + # DeleteGroup fails while policies are still attached + with pytest.raises(iam_root.exceptions.DeleteConflictException): + iam_root.delete_group(GroupName=name) + +@pytest.mark.group_policy +@pytest.mark.iam_account +def test_account_inline_group_policy_allow(iam_root): + path = get_iam_path_prefix() + username = make_iam_name('User') + groupname = make_iam_name('Group') + bucket_name = get_new_bucket_name() + + iam_root.create_user(UserName=username, Path=path) + + key = iam_root.create_access_key(UserName=username)['AccessKey'] + client = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + iam_root.create_group(GroupName=groupname, Path=path) + iam_root.add_user_to_group(GroupName=groupname, UserName=username) + + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, client.list_buckets) + # expect AccessDenied because no identity policy allows s3 actions + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a group policy that allows s3 actions + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + }) + policy_name = 'AllowStar' + iam_root.put_group_policy(GroupName=groupname, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, client.list_buckets) + +@pytest.mark.group_policy +@pytest.mark.iam_account +def test_account_managed_group_policy_allow(iam_root): + path = get_iam_path_prefix() + username = make_iam_name('User') + groupname = make_iam_name('Group') + bucket_name = get_new_bucket_name() + + iam_root.create_user(UserName=username, Path=path) + + key = iam_root.create_access_key(UserName=username)['AccessKey'] + client = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + iam_root.create_group(GroupName=groupname, Path=path) + iam_root.add_user_to_group(GroupName=groupname, UserName=username) + + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, client.list_buckets) + # expect AccessDenied because no identity policy allows s3 actions + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a group policy that allows s3 read actions + policy_arn = 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' + iam_root.attach_group_policy(GroupName=groupname, PolicyArn=policy_arn) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, client.list_buckets) + + +assume_role_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': '*'} + }] + }) + +# IAM Role apis +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_create(iam_root): + path = get_iam_path_prefix() + name1 = make_iam_name('R1') + desc = 'my role description' + max_duration = 43200 + response = iam_root.create_role(RoleName=name1, Path=path, AssumeRolePolicyDocument=assume_role_policy, Description=desc, MaxSessionDuration=max_duration) + role = response['Role'] + assert role['Path'] == path + assert role['RoleName'] == name1 + assert assume_role_policy == json.dumps(role['AssumeRolePolicyDocument']) + assert len(role['RoleId']) + arn = role['Arn'] + assert arn.startswith('arn:aws:iam:') + assert arn.endswith(f':role{path}{name1}') + assert role['CreateDate'] > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + # AWS doesn't include these for CreateRole, only GetRole + #assert desc == role['Description'] + #assert max_duration == role['MaxSessionDuration'] + + response = iam_root.get_role(RoleName=name1) + role = response['Role'] + assert arn == role['Arn'] + assert desc == role['Description'] + assert max_duration == role['MaxSessionDuration'] + + path2 = get_iam_path_prefix() + 'foo/' + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_role(RoleName=name1, Path=path2, AssumeRolePolicyDocument=assume_role_policy) + + name2 = make_iam_name('R2') + response = iam_root.create_role(RoleName=name2, Path=path2, AssumeRolePolicyDocument=assume_role_policy) + role = response['Role'] + assert role['Path'] == path2 + assert role['RoleName'] == name2 + +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_case_insensitive_name(iam_root): + path = get_iam_path_prefix() + name_upper = make_iam_name('R1') + name_lower = make_iam_name('r1') + response = iam_root.create_role(RoleName=name_upper, Path=path, AssumeRolePolicyDocument=assume_role_policy) + rid = response['Role']['RoleId'] + + # name is case-insensitive, so 'r1' should also conflict + with pytest.raises(iam_root.exceptions.EntityAlreadyExistsException): + iam_root.create_role(RoleName=name_lower, AssumeRolePolicyDocument=assume_role_policy) + + # search for 'r1' should return the same 'R1' role + response = iam_root.get_role(RoleName=name_lower) + assert rid == response['Role']['RoleId'] + + # delete for 'r1' should delete the same 'R1' role + iam_root.delete_role(RoleName=name_lower) + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_role(RoleName=name_lower) + +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_delete(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('U1') + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_role(RoleName=name) + + response = iam_root.create_role(RoleName=name, Path=path, AssumeRolePolicyDocument=assume_role_policy) + uid = response['Role']['RoleId'] + create_date = response['Role']['CreateDate'] + + iam_root.delete_role(RoleName=name) + + response = iam_root.create_role(RoleName=name, Path=path, AssumeRolePolicyDocument=assume_role_policy) + assert uid != response['Role']['RoleId'] + assert create_date <= response['Role']['CreateDate'] + +def role_list_names(client, **kwargs): + p = client.get_paginator('list_roles') + rolenames = [] + for response in p.paginate(**kwargs): + rolenames += [u['RoleName'] for u in response['Roles']] + return rolenames + +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_list(iam_root): + path = get_iam_path_prefix() + response = iam_root.list_roles(PathPrefix=path) + assert len(response['Roles']) == 0 + assert response['IsTruncated'] == False + + name1 = make_iam_name('aa') + name2 = make_iam_name('Ab') + name3 = make_iam_name('ac') + name4 = make_iam_name('Ad') + + # sort order is independent of CreateDate, Path, and RoleName capitalization + iam_root.create_role(RoleName=name4, Path=path+'w/', AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name3, Path=path+'x/', AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name2, Path=path+'y/', AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name1, Path=path+'z/', AssumeRolePolicyDocument=assume_role_policy) + + assert [name1, name2, name3, name4] == \ + role_list_names(iam_root, PathPrefix=path) + assert [name1, name2, name3, name4] == \ + role_list_names(iam_root, PathPrefix=path, PaginationConfig={'PageSize': 1}) + +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_list_path_prefix(iam_root): + path = get_iam_path_prefix() + response = iam_root.list_roles(PathPrefix=path) + assert len(response['Roles']) == 0 + assert response['IsTruncated'] == False + + name1 = make_iam_name('a') + name2 = make_iam_name('b') + name3 = make_iam_name('c') + name4 = make_iam_name('d') + + iam_root.create_role(RoleName=name1, Path=path, AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name2, Path=path, AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name3, Path=path+'a/', AssumeRolePolicyDocument=assume_role_policy) + iam_root.create_role(RoleName=name4, Path=path+'a/x/', AssumeRolePolicyDocument=assume_role_policy) + + assert [name1, name2, name3, name4] == \ + role_list_names(iam_root, PathPrefix=path) + assert [name1, name2, name3, name4] == \ + role_list_names(iam_root, PathPrefix=path, + PaginationConfig={'PageSize': 1}) + assert [name3, name4] == \ + role_list_names(iam_root, PathPrefix=path+'a') + assert [name3, name4] == \ + role_list_names(iam_root, PathPrefix=path+'a', + PaginationConfig={'PageSize': 1}) + assert [name4] == \ + role_list_names(iam_root, PathPrefix=path+'a/x') + assert [name4] == \ + role_list_names(iam_root, PathPrefix=path+'a/x', + PaginationConfig={'PageSize': 1}) + assert [] == role_list_names(iam_root, PathPrefix=path+'a/x/d') + +@pytest.mark.iam_account +@pytest.mark.iam_role +def test_account_role_update(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('a') + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.update_role(RoleName=name) + + iam_root.create_role(RoleName=name, Path=path, AssumeRolePolicyDocument=assume_role_policy) + + response = iam_root.get_role(RoleName=name) + assert name == response['Role']['RoleName'] + arn = response['Role']['Arn'] + rid = response['Role']['RoleId'] + + desc = 'my role description' + iam_root.update_role(RoleName=name, Description=desc, MaxSessionDuration=43200) + + response = iam_root.get_role(RoleName=name) + assert rid == response['Role']['RoleId'] + assert arn == response['Role']['Arn'] + assert desc == response['Role']['Description'] + assert 43200 == response['Role']['MaxSessionDuration'] + + +role_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + "Resource": "*" + }] + }) + +# IAM RolePolicy apis +@pytest.mark.iam_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_account_role_policy(iam_root): + path = get_iam_path_prefix() + role_name = make_iam_name('r') + policy_name = 'MyPolicy' + policy2_name = 'AnotherPolicy' + + # Get/Put/Delete fail on nonexistent RoleName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_role_policy(RoleName=role_name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=role_policy) + + iam_root.create_role(RoleName=role_name, Path=path, AssumeRolePolicyDocument=assume_role_policy) + + # Get/Delete fail on nonexistent PolicyName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_role_policy(RoleName=role_name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=role_policy) + + response = iam_root.get_role_policy(RoleName=role_name, PolicyName=policy_name) + assert role_name == response['RoleName'] + assert policy_name == response['PolicyName'] + assert role_policy == json.dumps(response['PolicyDocument']) + + response = iam_root.list_role_policies(RoleName=role_name) + assert [policy_name] == response['PolicyNames'] + + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy2_name, PolicyDocument=role_policy) + + response = iam_root.list_role_policies(RoleName=role_name) + assert [policy2_name, policy_name] == response['PolicyNames'] + + iam_root.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + iam_root.delete_role_policy(RoleName=role_name, PolicyName=policy2_name) + + # Get/Delete fail after Delete + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_role_policy(RoleName=role_name, PolicyName=policy_name) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + +@pytest.mark.role_policy +@pytest.mark.iam_account +def test_account_role_policy_managed(iam_root): + path = get_iam_path_prefix() + name = make_iam_name('name') + policy1 = 'arn:aws:iam::aws:policy/AmazonS3FullAccess' + policy2 = 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' + + # Attach/Detach/List fail on nonexistent RoleName + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.attach_role_policy(RoleName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_role_policy(RoleName=name, PolicyArn=policy1) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.list_attached_role_policies(RoleName=name) + + iam_root.create_role(RoleName=name, Path=path, AssumeRolePolicyDocument=assume_role_policy) + + # Detach fails on unattached PolicyArn + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_role_policy(RoleName=name, PolicyArn=policy1) + + iam_root.attach_role_policy(RoleName=name, PolicyArn=policy1) + iam_root.attach_role_policy(RoleName=name, PolicyArn=policy1) + + response = iam_root.list_attached_role_policies(RoleName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + iam_root.attach_role_policy(RoleName=name, PolicyArn=policy2) + + response = iam_root.list_attached_role_policies(RoleName=name) + policies = response['AttachedPolicies'] + assert len(policies) == 2 + names = [p['PolicyName'] for p in policies] + arns = [p['PolicyArn'] for p in policies] + assert 'AmazonS3FullAccess' in names + assert policy1 in arns + assert 'AmazonS3ReadOnlyAccess' in names + assert policy2 in arns + + iam_root.detach_role_policy(RoleName=name, PolicyArn=policy2) + + # Detach fails after Detach + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.detach_role_policy(RoleName=name, PolicyArn=policy2) + + response = iam_root.list_attached_role_policies(RoleName=name) + assert len(response['AttachedPolicies']) == 1 + assert 'AmazonS3FullAccess' == response['AttachedPolicies'][0]['PolicyName'] + assert policy1 == response['AttachedPolicies'][0]['PolicyArn'] + + # DeleteRole fails while policies are still attached + with pytest.raises(iam_root.exceptions.DeleteConflictException): + iam_root.delete_role(RoleName=name) + +@pytest.mark.iam_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_account_role_policy_allow(iam_root): + path = get_iam_path_prefix() + user_name = make_iam_name('MyUser') + role_name = make_iam_name('MyRole') + session_name = 'MySession' + + user = iam_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + + trust_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': user_arn} + }] + }) + # returns MalformedPolicyDocument until the user arn starts working + role = retry_on('MalformedPolicyDocument', 10, iam_root.create_role, + RoleName=role_name, Path=path, AssumeRolePolicyDocument=trust_policy)['Role'] + role_arn = role['Arn'] + + key = iam_root.create_access_key(UserName=user_name)['AccessKey'] + sts = get_sts_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # returns InvalidClientTokenId or AccessDenied until the access key starts working + response = retry_on(('InvalidClientTokenId', 'AccessDenied'), 10, sts.assume_role, + RoleArn=role_arn, RoleSessionName=session_name) + creds = response['Credentials'] + + s3 = get_iam_s3client(aws_access_key_id = creds['AccessKeyId'], + aws_secret_access_key = creds['SecretAccessKey'], + aws_session_token = creds['SessionToken']) + + # expect AccessDenied because no identity policy allows s3 actions + e = assert_raises(ClientError, s3.list_buckets) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + policy_name = 'AllowListAllMyBuckets' + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:ListAllMyBuckets', + 'Resource': '*' + }] + }) + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, s3.list_buckets) + +# alt account user assumes main account role to access main account bucket +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_same_account_role_policy_allow(iam_root, iam_alt_root): + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + role_name = make_iam_name('MyRole') + session_name = 'MySession' + bucket_name = get_new_bucket_name() + + user = iam_alt_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + key = iam_alt_root.create_access_key(UserName=user_name)['AccessKey'] + + s3_main = get_iam_root_client(service_name='s3') + s3_main.create_bucket(Bucket=bucket_name) + + trust_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': user_arn} + }] + }) + # returns MalformedPolicyDocument until the user arn starts working + role = retry_on('MalformedPolicyDocument', 10, iam_root.create_role, + RoleName=role_name, Path=path, AssumeRolePolicyDocument=trust_policy)['Role'] + role_arn = role['Arn'] + + sts = get_sts_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # returns InvalidClientTokenId or AccessDenied until the access key starts working + response = retry_on(('InvalidClientTokenId', 'AccessDenied'), 10, sts.assume_role, + RoleArn=role_arn, RoleSessionName=session_name) + creds = response['Credentials'] + + s3 = get_iam_s3client(aws_access_key_id = creds['AccessKeyId'], + aws_secret_access_key = creds['SecretAccessKey'], + aws_session_token = creds['SessionToken']) + + # expect AccessDenied because no identity policy allows s3 actions + e = assert_raises(ClientError, s3.list_objects, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + policy_name = 'AllowListBucket' + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:ListBucket', + 'Resource': '*' + }] + }) + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, s3.list_objects, Bucket=bucket_name) + +# alt account user assumes main account role to access alt account bucket +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_cross_account_role_policy_allow(iam_root, iam_alt_root): + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + role_name = make_iam_name('MyRole') + session_name = 'MySession' + bucket_name = get_new_bucket_name() + + user = iam_alt_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + key = iam_alt_root.create_access_key(UserName=user_name)['AccessKey'] + + s3_alt = get_iam_alt_root_client(service_name='s3') + s3_alt.create_bucket(Bucket=bucket_name) + + trust_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': user_arn} + }] + }) + # returns MalformedPolicyDocument until the user arn starts working + role = retry_on('MalformedPolicyDocument', 10, iam_root.create_role, + RoleName=role_name, Path=path, AssumeRolePolicyDocument=trust_policy)['Role'] + role_arn = role['Arn'] + + sts = get_sts_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # returns InvalidClientTokenId or AccessDenied until the access key starts working + response = retry_on(('InvalidClientTokenId', 'AccessDenied'), 10, sts.assume_role, + RoleArn=role_arn, RoleSessionName=session_name) + creds = response['Credentials'] + + s3 = get_iam_s3client(aws_access_key_id = creds['AccessKeyId'], + aws_secret_access_key = creds['SecretAccessKey'], + aws_session_token = creds['SessionToken']) + + # expect AccessDenied because no identity policy allows s3 actions + e = assert_raises(ClientError, s3.list_objects, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + policy_name = 'AllowListBucket' + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:ListBucket', + 'Resource': '*' + }] + }) + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy) + + # expect AccessDenied because no resource policy allows the main account + e = assert_raises(ClientError, s3.list_objects, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket policy that allows s3:ListBucket for the main account's arn + main_arn = iam_root.get_user()['User']['Arn'] + s3_alt.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': main_arn}, + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket_name}' + }] + })) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, s3.list_objects, Bucket=bucket_name) + +# alt account user assumes main account role to create a bucket +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_account_role_policy_allow_create_bucket(iam_root, iam_alt_root): + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + role_name = make_iam_name('MyRole') + session_name = 'MySession' + bucket_name = get_new_bucket_name() + + user = iam_alt_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + key = iam_alt_root.create_access_key(UserName=user_name)['AccessKey'] + + trust_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': user_arn} + }] + }) + # returns MalformedPolicyDocument until the user arn starts working + role = retry_on('MalformedPolicyDocument', 10, iam_root.create_role, + RoleName=role_name, Path=path, AssumeRolePolicyDocument=trust_policy)['Role'] + role_arn = role['Arn'] + + sts = get_sts_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # returns InvalidClientTokenId or AccessDenied until the access key starts working + response = retry_on(('InvalidClientTokenId', 'AccessDenied'), 10, sts.assume_role, + RoleArn=role_arn, RoleSessionName=session_name) + creds = response['Credentials'] + + s3 = get_iam_s3client(aws_access_key_id = creds['AccessKeyId'], + aws_secret_access_key = creds['SecretAccessKey'], + aws_session_token = creds['SessionToken']) + + # expect AccessDenied because no identity policy allows s3 actions + e = assert_raises(ClientError, s3.create_bucket, Bucket=bucket_name, ObjectOwnership='ObjectWriter', ACL='private') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + policy_name = 'AllowCreateBucket' + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': ['s3:CreateBucket', 's3:PutBucketAcl'], + 'Resource': '*' + }] + }) + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, s3.create_bucket, Bucket=bucket_name, ObjectOwnership='ObjectWriter', ACL='private') + + # verify that the bucket is owned by the role's account + s3_main = get_iam_root_client(service_name='s3') + response = s3_main.get_bucket_acl(Bucket=bucket_name) + + account_id = get_iam_root_account_id() + assert response['Owner']['ID'] == account_id + assert response['Grants'][0]['Grantee']['ID'] == account_id + +# alt account user assumes main account role to read the role info +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.iam_role +@pytest.mark.role_policy +def test_account_role_policy_allow_get_role(iam_root, iam_alt_root): + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + role_name = make_iam_name('MyRole') + session_name = 'MySession' + bucket_name = get_new_bucket_name() + + user = iam_alt_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + key = iam_alt_root.create_access_key(UserName=user_name)['AccessKey'] + + trust_policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'sts:AssumeRole', + 'Principal': {'AWS': user_arn} + }] + }) + # returns MalformedPolicyDocument until the user arn starts working + role = retry_on('MalformedPolicyDocument', 10, iam_root.create_role, + RoleName=role_name, Path=path, AssumeRolePolicyDocument=trust_policy)['Role'] + role_arn = role['Arn'] + + sts = get_sts_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # returns InvalidClientTokenId or AccessDenied until the access key starts working + response = retry_on(('InvalidClientTokenId', 'AccessDenied'), 10, sts.assume_role, + RoleArn=role_arn, RoleSessionName=session_name) + creds = response['Credentials'] + + iam = get_iam_root_client(service_name='iam', + aws_access_key_id = creds['AccessKeyId'], + aws_secret_access_key = creds['SecretAccessKey'], + aws_session_token = creds['SessionToken']) + + # expect AccessDenied because no identity policy allows iam actions + e = assert_raises(ClientError, iam.get_role, RoleName=role_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + policy_name = 'AllowGetRole' + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 'iam:GetRole', + 'Resource': '*' + }] + }) + iam_root.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy) + + # the policy may take a bit to start working. retry until it returns + # something other than AccessDenied + retry_on('AccessDenied', 10, iam.get_role, RoleName=role_name) + + +# IAM OpenIDConnectProvider apis +@pytest.mark.iam_account +def test_account_oidc_provider(iam_root): + url_host = get_iam_path_prefix()[1:] + 'example.com' + url = 'http://' + url_host + + response = iam_root.create_open_id_connect_provider( + ClientIDList=['my-application-id'], + ThumbprintList=['3768084dfb3d2b68b7897bf5f565da8efEXAMPLE'], + Url=url) + arn = response['OpenIDConnectProviderArn'] + assert arn.endswith(f':oidc-provider/{url_host}') + + response = iam_root.list_open_id_connect_providers() + arns = [p['Arn'] for p in response['OpenIDConnectProviderList']] + assert arn in arns + + response = iam_root.get_open_id_connect_provider(OpenIDConnectProviderArn=arn) + assert url == response['Url'] + assert ['my-application-id'] == response['ClientIDList'] + assert ['3768084dfb3d2b68b7897bf5f565da8efEXAMPLE'] == response['ThumbprintList'] + + iam_root.delete_open_id_connect_provider(OpenIDConnectProviderArn=arn) + + response = iam_root.list_open_id_connect_providers() + arns = [p['Arn'] for p in response['OpenIDConnectProviderList']] + assert arn not in arns + + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.get_open_id_connect_provider(OpenIDConnectProviderArn=arn) + with pytest.raises(iam_root.exceptions.NoSuchEntityException): + iam_root.delete_open_id_connect_provider(OpenIDConnectProviderArn=arn) + + +@pytest.mark.iam_account +def test_verify_add_new_client_id_to_oidc(iam_root): + url_host = get_iam_path_prefix()[1:] + 'example.com' + url = 'http://' + url_host + + response = iam_root.create_open_id_connect_provider( + Url=url, + ClientIDList=[ + 'app-jee-jsp', + ], + ThumbprintList=[ + '3768084dfb3d2b68b7897bf5f565da8efEXAMPLE' + ] + ) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ClientIDList']) == 1 + assert get_response['ClientIDList'][0] == 'app-jee-jsp' + assert url == get_response['Url'] + + add_response = iam_root.add_client_id_to_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'], + ClientID='app-profile-jsp' + ) + assert add_response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert len(get_response['ClientIDList']) == 2 + assert get_response['ClientIDList'][0] == 'app-jee-jsp' + assert get_response['ClientIDList'][1] == 'app-profile-jsp' + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + del_response = iam_root.delete_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert del_response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_verify_add_existing_client_id_to_oidc(iam_root): + url_host = get_iam_path_prefix()[1:] + 'example.com' + url = 'http://' + url_host + + response = iam_root.create_open_id_connect_provider( + Url=url, + ClientIDList=[ + 'app-jee-jsp', + 'app-profile-jsp' + ], + ThumbprintList=[ + '3768084dfb3d2b68b7897bf5f565da8efEXAMPLE' + ] + ) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ClientIDList']) == 2 + assert get_response['ClientIDList'][0] == 'app-jee-jsp' + assert get_response['ClientIDList'][1] == 'app-profile-jsp' + add_response = iam_root.add_client_id_to_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'], + ClientID='app-profile-jsp' + ) + assert add_response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert len(get_response['ClientIDList']) == 2 + assert get_response['ClientIDList'][0] == 'app-jee-jsp' + assert get_response['ClientIDList'][1] == 'app-profile-jsp' + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + del_response = iam_root.delete_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert del_response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.iam_account +def test_verify_remove_client_id_from_oidc(iam_root): + url_host = get_iam_path_prefix()[1:] + 'example.com' + url = 'http://' + url_host + + response = iam_root.create_open_id_connect_provider( + Url=url, + ClientIDList=['app-jee-jsp', 'app-profile-jsp'], + ThumbprintList=['3768084dfb3d2b68b7897bf5f565da8efEXAMPLE'] + ) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_provider_arn = response['OpenIDConnectProviderArn'] + + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_provider_arn + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ClientIDList']) == 2 + assert 'app-jee-jsp' in get_response['ClientIDList'] + assert 'app-profile-jsp' in get_response['ClientIDList'] + + remove_response = iam_root.remove_client_id_from_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_provider_arn, + ClientID='app-profile-jsp' + ) + assert remove_response['ResponseMetadata']['HTTPStatusCode'] == 200 + + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_provider_arn + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ClientIDList']) == 1 + assert get_response['ClientIDList'][0] == 'app-jee-jsp' + assert 'app-profile-jsp' not in get_response['ClientIDList'] + + del_response = iam_root.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_provider_arn + ) + assert del_response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_verify_update_thumbprintlist_of_oidc(iam_root): + url_host = get_iam_path_prefix()[1:] + 'example.com' + url = 'http://' + url_host + + response = iam_root.create_open_id_connect_provider( + Url=url, + ClientIDList=[ + 'app-jee-jsp', + 'app-profile-jsp' + ], + ThumbprintList=[ + '3768084dfb3d2b68b7897bf5f565da8efEXAMPLE' + ] + ) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ThumbprintList']) == 1 + assert get_response['ThumbprintList'][0] == '3768084dfb3d2b68b7897bf5f565da8efEXAMPLE' + update_response = iam_root.update_open_id_connect_provider_thumbprint( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'], + ThumbprintList=[ + '3768084dfb3d2b68b7897bf5f565da8efSAMPLE1' + ] + ) + assert update_response['ResponseMetadata']['HTTPStatusCode'] == 200 + get_response = iam_root.get_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert len(get_response['ThumbprintList']) == 1 + assert get_response['ThumbprintList'][0] == '3768084dfb3d2b68b7897bf5f565da8efSAMPLE1' + del_response = iam_root.delete_open_id_connect_provider( + OpenIDConnectProviderArn=response['OpenIDConnectProviderArn'] + ) + assert del_response['ResponseMetadata']['HTTPStatusCode'] == 200 + +# test cross-account access, adding user policy before the bucket policy +def _test_cross_account_user_bucket_policy(roots3, alt_root, alt_name, alt_arn): + # add a user policy that allows s3 actions + alt_root.put_user_policy(UserName=alt_name, PolicyName='AllowStar', PolicyDocument=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + })) + + key = alt_root.create_access_key(UserName=alt_name)['AccessKey'] + alts3 = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket policy that allows s3:ListBucket for the iam user's arn + roots3.put_bucket_policy(Bucket=bucket, Policy=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': alt_arn}, + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket}' + }] + })) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +# test cross-account access, adding bucket policy before the user policy +def _test_cross_account_bucket_user_policy(roots3, alt_root, alt_name, alt_arn): + key = alt_root.create_access_key(UserName=alt_name)['AccessKey'] + alts3 = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + # add a bucket policy that allows s3:ListBucket for the iam user's arn + roots3.put_bucket_policy(Bucket=bucket, Policy=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': alt_arn}, + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket}' + }] + })) + + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a user policy that allows s3 actions + alt_root.put_user_policy(UserName=alt_name, PolicyName='AllowStar', PolicyDocument=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + })) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_bucket_user_policy_allow_user_arn(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + user_arn = response['User']['Arn'] + _test_cross_account_bucket_user_policy(roots3, iam_alt_root, user_name, user_arn) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_user_bucket_policy_allow_user_arn(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + user_arn = response['User']['Arn'] + _test_cross_account_user_bucket_policy(roots3, iam_alt_root, user_name, user_arn) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_user_bucket_policy_allow_account_arn(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + user_arn = response['User']['Arn'] + account_arn = user_arn.replace(f':user{path}{user_name}', ':root') + _test_cross_account_user_bucket_policy(roots3, iam_alt_root, user_name, account_arn) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_bucket_user_policy_allow_account_arn(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + user_arn = response['User']['Arn'] + account_arn = user_arn.replace(f':user{path}{user_name}', ':root') + _test_cross_account_bucket_user_policy(roots3, iam_alt_root, user_name, account_arn) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_user_bucket_policy_allow_account_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + + account_id = get_iam_alt_root_account_id() + _test_cross_account_user_bucket_policy(roots3, iam_alt_root, user_name, account_id) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_bucket_user_policy_allow_account_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + + account_id = get_iam_alt_root_account_id() + _test_cross_account_bucket_user_policy(roots3, iam_alt_root, user_name, account_id) + + +# test cross-account access, adding user policy before the bucket acl +def _test_cross_account_user_policy_bucket_acl(roots3, alt_root, alt_name, grantee): + # add a user policy that allows s3 actions + alt_root.put_user_policy(UserName=alt_name, PolicyName='AllowStar', PolicyDocument=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + })) + + key = alt_root.create_access_key(UserName=alt_name)['AccessKey'] + alts3 = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket acl that grants READ access + roots3.put_bucket_acl(Bucket=bucket, GrantRead=grantee) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +# test cross-account access, adding bucket acl before the user policy +def _test_cross_account_bucket_acl_user_policy(roots3, alt_root, alt_name, grantee): + key = alt_root.create_access_key(UserName=alt_name)['AccessKey'] + alts3 = get_iam_s3client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + # add a bucket acl that grants READ access + roots3.put_bucket_acl(Bucket=bucket, GrantRead=grantee) + + # the access key may take a bit to start working. retry until it returns + # something other than InvalidAccessKeyId + e = assert_raises(ClientError, retry_on, 'InvalidAccessKeyId', 10, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a user policy that allows s3 actions + alt_root.put_user_policy(UserName=alt_name, PolicyName='AllowStar', PolicyDocument=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Action': 's3:*', + 'Resource': '*' + }] + })) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.fails_on_aws # can't grant to individual users +def test_cross_account_bucket_acl_user_policy_grant_user_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'id=' + response['User']['UserId'] + _test_cross_account_bucket_acl_user_policy(roots3, iam_alt_root, user_name, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.fails_on_aws # can't grant to individual users +def test_cross_account_user_policy_bucket_acl_grant_user_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + response = iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'id=' + response['User']['UserId'] + _test_cross_account_user_policy_bucket_acl(roots3, iam_alt_root, user_name, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_bucket_acl_user_policy_grant_canonical_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'id=' + get_iam_alt_root_user_id() + _test_cross_account_bucket_acl_user_policy(roots3, iam_alt_root, user_name, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_user_policy_bucket_acl_grant_canonical_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'id=' + get_iam_alt_root_user_id() + _test_cross_account_user_policy_bucket_acl(roots3, iam_alt_root, user_name, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_bucket_acl_user_policy_grant_account_email(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'emailAddress=' + get_iam_alt_root_email() + _test_cross_account_bucket_acl_user_policy(roots3, iam_alt_root, user_name, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_user_policy_bucket_acl_grant_account_email(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + path = get_iam_path_prefix() + user_name = make_iam_name('AltUser') + iam_alt_root.create_user(UserName=user_name, Path=path) + grantee = 'emailAddress=' + get_iam_alt_root_email() + _test_cross_account_user_policy_bucket_acl(roots3, iam_alt_root, user_name, grantee) + + +# test root cross-account access with bucket policy +def _test_cross_account_root_bucket_policy(roots3, alts3, alt_arn): + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + e = assert_raises(ClientError, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket policy that allows s3:ListBucket for the iam user's arn + roots3.put_bucket_policy(Bucket=bucket, Policy=json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': alt_arn}, + 'Action': 's3:ListBucket', + 'Resource': f'arn:aws:s3:::{bucket}' + }] + })) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_root_bucket_policy_allow_account_arn(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + alts3 = get_iam_alt_root_client(service_name='s3') + alt_arn = iam_alt_root.get_user()['User']['Arn'] + _test_cross_account_root_bucket_policy(roots3, alts3, alt_arn) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_root_bucket_policy_allow_account_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + alts3 = get_iam_alt_root_client(service_name='s3') + + account_id = get_iam_alt_root_account_id() + _test_cross_account_root_bucket_policy(roots3, alts3, account_id) + +# test root cross-account access with bucket acls +def _test_cross_account_root_bucket_acl(roots3, alts3, grantee): + # create a bucket with the root user + bucket = get_new_bucket(roots3) + try: + e = assert_raises(ClientError, alts3.list_objects, Bucket=bucket) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add a bucket acl that grants READ + roots3.put_bucket_acl(Bucket=bucket, GrantRead=grantee) + + # verify that the iam user can eventually access it + retry_on('AccessDenied', 10, alts3.list_objects, Bucket=bucket) + finally: + roots3.delete_bucket(Bucket=bucket) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_root_bucket_acl_grant_canonical_id(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + alts3 = get_iam_alt_root_client(service_name='s3') + grantee = 'id=' + get_iam_alt_root_user_id() + _test_cross_account_root_bucket_acl(roots3, alts3, grantee) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +def test_cross_account_root_bucket_acl_grant_account_email(iam_root, iam_alt_root): + roots3 = get_iam_root_client(service_name='s3') + alts3 = get_iam_alt_root_client(service_name='s3') + grantee = 'emailAddress=' + get_iam_alt_root_email() + _test_cross_account_root_bucket_acl(roots3, alts3, grantee) + +@pytest.mark.iam_account +def test_get_account_summary_root(iam_root): + summary = iam_root.get_account_summary()['SummaryMap'] + assert 'Users' in summary + assert 'Groups' in summary + assert 'UsersQuota' in summary + assert 'GroupsQuota' in summary + assert 'AccessKeysPerUserQuota' in summary + +@pytest.mark.iam_account +def test_get_account_summary_policy(iam_root): + path = get_iam_path_prefix() + user_name = make_iam_name('MyUser') + iam_root.create_user(UserName=user_name, Path=path)['User'] + + policy_name = "AllowIamAction" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "iam:GetAccountSummary", + "Resource": "*" + } + ] + } + + iam_root.put_user_policy( + UserName=user_name, + PolicyName=policy_name, + PolicyDocument=json.dumps(policy_document) + ) + + key = iam_root.create_access_key(UserName=user_name)['AccessKey'] + iam_client = get_iam_client(aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + summary = iam_client.get_account_summary()['SummaryMap'] + assert 'Users' in summary + assert 'Groups' in summary + assert 'UsersQuota' in summary + assert 'GroupsQuota' in summary + assert 'AccessKeysPerUserQuota' in summary diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_s3.py b/src/test/rgw/s3-tests/s3tests/functional/test_s3.py new file mode 100644 index 00000000000..9878c248606 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_s3.py @@ -0,0 +1,20779 @@ +import boto3 +import botocore.session +import botocore.config +from botocore.exceptions import ClientError +from botocore.exceptions import ParamValidationError +from botocore.handlers import validate_bucket_name +import isodate +import email.utils +import datetime +import threading +import re +import pytz +from collections import OrderedDict +import requests +import json +import base64 +import hmac +import hashlib +import xml.etree.ElementTree as ET +import time +import operator +import pytest +import os +import string +import random +import socket +import dateutil.parser +import ssl +import pdb +from collections import namedtuple +from collections import defaultdict +from io import BytesIO + +from .utils import assert_raises +from .utils import generate_random +from .utils import _get_status_and_error_code +from .utils import _get_status + +from .policy import Policy, Statement, make_json_policy + +from .iam import iam_root, nuke_role + +from . import ( + configfile, + setup_teardown, + get_client, + get_prefix, + get_unauthenticated_client, + get_bad_auth_client, + get_v2_client, + get_new_bucket, + get_new_bucket_name, + get_new_bucket_resource, + get_config_is_secure, + get_config_host, + get_config_port, + get_config_endpoint, + get_config_ssl_verify, + get_main_aws_access_key, + get_main_aws_secret_key, + get_main_display_name, + get_main_user_id, + get_main_email, + get_main_api_name, + get_alt_aws_access_key, + get_alt_aws_secret_key, + get_alt_display_name, + get_alt_user_id, + get_alt_email, + get_alt_client, + get_iam_root_client, + get_iam_root_s3client, + get_tenant_client, + get_v2_tenant_client, + get_tenant_iam_client, + get_tenant_name, + get_tenant_user_id, + get_buckets_list, + get_objects_list, + get_main_kms_keyid, + get_secondary_kms_keyid, + get_svc_client, + get_cloud_storage_class, + get_cloud_retain_head_object, + get_allow_read_through, + get_cloud_regular_storage_class, + get_cloud_target_path, + get_cloud_target_storage_class, + get_cloud_target_by_bucket, + get_cloud_target_by_bucket_prefix, + get_cloud_client, + nuke_prefixed_buckets, + configured_storage_classes, + configure, + get_lc_debug_interval, + get_restore_debug_interval, + get_restore_processor_period, + get_read_through_days, + create_iam_user_s3client, + get_iam_client, + get_sts_client, + get_parameter_name, + ) + + +def _bucket_is_empty(bucket): + is_empty = True + for obj in bucket.objects.all(): + is_empty = False + break + return is_empty + +def test_bucket_list_empty(): + bucket = get_new_bucket_resource() + is_empty = _bucket_is_empty(bucket) + assert is_empty == True + +@pytest.mark.list_objects_v2 +def test_bucket_list_distinct(): + bucket1 = get_new_bucket_resource() + bucket2 = get_new_bucket_resource() + obj = bucket1.put_object(Body='str', Key='asdf') + is_empty = _bucket_is_empty(bucket2) + assert is_empty == True + +def _create_objects(bucket=None, bucket_name=None, keys=[], put_object_args={}): + """ + Populate a (specified or new) bucket with objects with + specified names (and contents identical to their names). + """ + if bucket_name is None: + bucket_name = get_new_bucket_name() + if bucket is None: + bucket = get_new_bucket_resource(name=bucket_name) + + for key in keys: + obj = bucket.put_object(Body=key, Key=key, **put_object_args) + + return bucket_name + +def _get_keys(response): + """ + return lists of strings that are the keys from a client.list_objects() response + """ + keys = [] + if 'Contents' in response: + objects_list = response['Contents'] + keys = [obj['Key'] for obj in objects_list] + return keys + +def _get_prefixes(response): + """ + return lists of strings that are prefixes from a client.list_objects() response + """ + prefixes = [] + if 'CommonPrefixes' in response: + prefix_list = response['CommonPrefixes'] + prefixes = [prefix['Prefix'] for prefix in prefix_list] + return prefixes + +@pytest.mark.fails_on_dbstore +def test_bucket_list_many(): + bucket_name = _create_objects(keys=['foo', 'bar', 'baz']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, MaxKeys=2) + keys = _get_keys(response) + assert len(keys) == 2 + assert keys == ['bar', 'baz'] + assert response['IsTruncated'] == True + + response = client.list_objects(Bucket=bucket_name, Marker='baz',MaxKeys=2) + keys = _get_keys(response) + assert len(keys) == 1 + assert response['IsTruncated'] == False + assert keys == ['foo'] + +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_many(): + bucket_name = _create_objects(keys=['foo', 'bar', 'baz']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=2) + keys = _get_keys(response) + assert len(keys) == 2 + assert keys == ['bar', 'baz'] + assert response['IsTruncated'] == True + + response = client.list_objects_v2(Bucket=bucket_name, StartAfter='baz',MaxKeys=2) + keys = _get_keys(response) + assert len(keys) == 1 + assert response['IsTruncated'] == False + assert keys == ['foo'] + +@pytest.mark.list_objects_v2 +def test_basic_key_count(): + client = get_client() + bucket_names = [] + bucket_name = get_new_bucket_name() + client.create_bucket(Bucket=bucket_name) + for j in range(5): + client.put_object(Bucket=bucket_name, Key=str(j)) + response1 = client.list_objects_v2(Bucket=bucket_name) + assert response1['KeyCount'] == 5 + +def test_bucket_list_delimiter_basic(): + bucket_name = _create_objects(keys=['foo/bar', 'foo/bar/xyzzy', 'quux/thud', 'asdf']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='/') + assert response['Delimiter'] == '/' + keys = _get_keys(response) + assert keys == ['asdf'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + assert prefixes == ['foo/', 'quux/'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_basic(): + bucket_name = _create_objects(keys=['foo/bar', 'foo/bar/xyzzy', 'quux/thud', 'asdf']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='/') + assert response['Delimiter'] == '/' + keys = _get_keys(response) + assert keys == ['asdf'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + assert prefixes == ['foo/', 'quux/'] + assert response['KeyCount'] == len(prefixes) + len(keys) + + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_encoding_basic(): + bucket_name = _create_objects(keys=['foo+1/bar', 'foo/bar/xyzzy', 'quux ab/thud', 'asdf+b']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='/', EncodingType='url') + assert response['Delimiter'] == '/' + keys = _get_keys(response) + assert keys == ['asdf%2Bb'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 3 + assert prefixes == ['foo%2B1/', 'foo/', 'quux%20ab/'] + +def test_bucket_list_encoding_basic(): + bucket_name = _create_objects(keys=['foo+1/bar', 'foo/bar/xyzzy', 'quux ab/thud', 'asdf+b']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='/', EncodingType='url') + assert response['Delimiter'] == '/' + keys = _get_keys(response) + assert keys == ['asdf%2Bb'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 3 + assert prefixes == ['foo%2B1/', 'foo/', 'quux%20ab/'] + + +def validate_bucket_list(bucket_name, prefix, delimiter, marker, max_keys, + is_truncated, check_objs, check_prefixes, next_marker): + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter=delimiter, Marker=marker, MaxKeys=max_keys, Prefix=prefix) + assert response['IsTruncated'] == is_truncated + if 'NextMarker' not in response: + response['NextMarker'] = None + assert response['NextMarker'] == next_marker + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + + assert len(keys) == len(check_objs) + assert len(prefixes) == len(check_prefixes) + assert keys == check_objs + assert prefixes == check_prefixes + + return response['NextMarker'] + +def validate_bucket_listv2(bucket_name, prefix, delimiter, continuation_token, max_keys, + is_truncated, check_objs, check_prefixes, last=False): + client = get_client() + + params = dict(Bucket=bucket_name, Delimiter=delimiter, MaxKeys=max_keys, Prefix=prefix) + if continuation_token is not None: + params['ContinuationToken'] = continuation_token + else: + params['StartAfter'] = '' + response = client.list_objects_v2(**params) + assert response['IsTruncated'] == is_truncated + if 'NextContinuationToken' not in response: + response['NextContinuationToken'] = None + if last: + assert response['NextContinuationToken'] == None + + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + + assert len(keys) == len(check_objs) + assert len(prefixes) == len(check_prefixes) + assert keys == check_objs + assert prefixes == check_prefixes + + return response['NextContinuationToken'] + +@pytest.mark.fails_on_dbstore +def test_bucket_list_delimiter_prefix(): + bucket_name = _create_objects(keys=['asdf', 'boo/bar', 'boo/baz/xyzzy', 'cquux/thud', 'cquux/bla']) + + delim = '/' + marker = '' + prefix = '' + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 1, True, ['asdf'], [], 'asdf') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, True, [], ['boo/'], 'boo/') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, False, [], ['cquux/'], None) + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 2, True, ['asdf'], ['boo/'], 'boo/') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 2, False, [], ['cquux/'], None) + + prefix = 'boo/' + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 1, True, ['boo/bar'], [], 'boo/bar') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, False, [], ['boo/baz/'], None) + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 2, False, ['boo/bar'], ['boo/baz/'], None) + +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_delimiter_prefix(): + bucket_name = _create_objects(keys=['asdf', 'boo/bar', 'boo/baz/xyzzy', 'cquux/thud', 'cquux/bla']) + + delim = '/' + continuation_token = '' + prefix = '' + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 1, True, ['asdf'], []) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token, 1, True, [], ['boo/']) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token, 1, False, [], ['cquux/'], last=True) + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 2, True, ['asdf'], ['boo/']) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token, 2, False, [], ['cquux/'], last=True) + + prefix = 'boo/' + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 1, True, ['boo/bar'], []) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token, 1, False, [], ['boo/baz/'], last=True) + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 2, False, ['boo/bar'], ['boo/baz/'], last=True) + + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_prefix_ends_with_delimiter(): + bucket_name = _create_objects(keys=['asdf/']) + validate_bucket_listv2(bucket_name, 'asdf/', '/', None, 1000, False, ['asdf/'], [], last=True) + +def test_bucket_list_delimiter_prefix_ends_with_delimiter(): + bucket_name = _create_objects(keys=['asdf/']) + validate_bucket_list(bucket_name, 'asdf/', '/', '', 1000, False, ['asdf/'], [], None) + +def test_bucket_list_delimiter_alt(): + bucket_name = _create_objects(keys=['bar', 'baz', 'cab', 'foo']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='a') + assert response['Delimiter'] == 'a' + + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + # bar, baz, and cab should be broken up by the 'a' delimiters + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + assert prefixes == ['ba', 'ca'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_alt(): + bucket_name = _create_objects(keys=['bar', 'baz', 'cab', 'foo']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='a') + assert response['Delimiter'] == 'a' + + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + # bar, baz, and cab should be broken up by the 'a' delimiters + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + assert prefixes == ['ba', 'ca'] + +@pytest.mark.fails_on_dbstore +def test_bucket_list_delimiter_prefix_underscore(): + bucket_name = _create_objects(keys=['_obj1_','_under1/bar', '_under1/baz/xyzzy', '_under2/thud', '_under2/bla']) + + delim = '/' + marker = '' + prefix = '' + marker = validate_bucket_list(bucket_name, prefix, delim, '', 1, True, ['_obj1_'], [], '_obj1_') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, True, [], ['_under1/'], '_under1/') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, False, [], ['_under2/'], None) + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 2, True, ['_obj1_'], ['_under1/'], '_under1/') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 2, False, [], ['_under2/'], None) + + prefix = '_under1/' + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 1, True, ['_under1/bar'], [], '_under1/bar') + marker = validate_bucket_list(bucket_name, prefix, delim, marker, 1, False, [], ['_under1/baz/'], None) + + marker = validate_bucket_list(bucket_name, prefix, delim, '', 2, False, ['_under1/bar'], ['_under1/baz/'], None) + +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_delimiter_prefix_underscore(): + bucket_name = _create_objects(keys=['_obj1_','_under1/bar', '_under1/baz/xyzzy', '_under2/thud', '_under2/bla']) + + delim = '/' + continuation_token = '' + prefix = '' + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 1, True, ['_obj1_'], []) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token , 1, True, [], ['_under1/']) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token , 1, False, [], ['_under2/'], last=True) + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 2, True, ['_obj1_'], ['_under1/']) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token , 2, False, [], ['_under2/'], last=True) + + prefix = '_under1/' + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 1, True, ['_under1/bar'], []) + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, continuation_token , 1, False, [], ['_under1/baz/'], last=True) + + continuation_token = validate_bucket_listv2(bucket_name, prefix, delim, None, 2, False, ['_under1/bar'], ['_under1/baz/'], last=True) + + +def test_bucket_list_delimiter_percentage(): + bucket_name = _create_objects(keys=['b%ar', 'b%az', 'c%ab', 'foo']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='%') + assert response['Delimiter'] == '%' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b%', 'c%'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_percentage(): + bucket_name = _create_objects(keys=['b%ar', 'b%az', 'c%ab', 'foo']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='%') + assert response['Delimiter'] == '%' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b%', 'c%'] + +def test_bucket_list_delimiter_whitespace(): + bucket_name = _create_objects(keys=['b ar', 'b az', 'c ab', 'foo']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter=' ') + assert response['Delimiter'] == ' ' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b ', 'c '] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_whitespace(): + bucket_name = _create_objects(keys=['b ar', 'b az', 'c ab', 'foo']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter=' ') + assert response['Delimiter'] == ' ' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b ', 'c '] + +def test_bucket_list_delimiter_dot(): + bucket_name = _create_objects(keys=['b.ar', 'b.az', 'c.ab', 'foo']) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='.') + assert response['Delimiter'] == '.' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b.', 'c.'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_dot(): + bucket_name = _create_objects(keys=['b.ar', 'b.az', 'c.ab', 'foo']) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='.') + assert response['Delimiter'] == '.' + keys = _get_keys(response) + # foo contains no 'a' and so is a complete key + assert keys == ['foo'] + + prefixes = _get_prefixes(response) + assert len(prefixes) == 2 + # bar, baz, and cab should be broken up by the 'a' delimiters + assert prefixes == ['b.', 'c.'] + +def test_bucket_list_delimiter_unreadable(): + key_names=['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='\x0a') + assert response['Delimiter'] == '\x0a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_unreadable(): + key_names=['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='\x0a') + assert response['Delimiter'] == '\x0a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +def test_bucket_list_delimiter_empty(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='') + # putting an empty value into Delimiter will not return a value in the response + assert not 'Delimiter' in response + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_empty(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='') + # putting an empty value into Delimiter will not return a value in the response + assert not 'Delimiter' in response + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +def test_bucket_list_delimiter_none(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name) + # putting an empty value into Delimiter will not return a value in the response + assert not 'Delimiter' in response + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_none(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name) + # putting an empty value into Delimiter will not return a value in the response + assert not 'Delimiter' in response + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_fetchowner_notempty(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, FetchOwner=True) + objs_list = response['Contents'] + assert 'Owner' in objs_list[0] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_fetchowner_defaultempty(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name) + objs_list = response['Contents'] + assert not 'Owner' in objs_list[0] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_fetchowner_empty(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, FetchOwner= False) + objs_list = response['Contents'] + assert not 'Owner' in objs_list[0] + +def test_bucket_list_delimiter_not_exist(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='/') + # putting an empty value into Delimiter will not return a value in the response + assert response['Delimiter'] == '/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_delimiter_not_exist(): + key_names = ['bar', 'baz', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='/') + # putting an empty value into Delimiter will not return a value in the response + assert response['Delimiter'] == '/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + + +@pytest.mark.fails_on_dbstore +def test_bucket_list_delimiter_not_skip_special(): + key_names = ['0/'] + ['0/%s' % i for i in range(1000, 1999)] + key_names2 = ['1999', '1999#', '1999+', '2000'] + key_names += key_names2 + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='/') + assert response['Delimiter'] == '/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names2 + assert prefixes == ['0/'] + +def test_bucket_list_prefix_basic(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='foo/') + assert response['Prefix'] == 'foo/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['foo/bar', 'foo/baz'] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_basic(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='foo/') + assert response['Prefix'] == 'foo/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['foo/bar', 'foo/baz'] + assert prefixes == [] + +# just testing that we can do the delimeter and prefix logic on non-slashes +def test_bucket_list_prefix_alt(): + key_names = ['bar', 'baz', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='ba') + assert response['Prefix'] == 'ba' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['bar', 'baz'] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_alt(): + key_names = ['bar', 'baz', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='ba') + assert response['Prefix'] == 'ba' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['bar', 'baz'] + assert prefixes == [] + +def test_bucket_list_prefix_empty(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='') + assert response['Prefix'] == '' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_empty(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='') + assert response['Prefix'] == '' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +def test_bucket_list_prefix_none(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='') + assert response['Prefix'] == '' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_none(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='') + assert response['Prefix'] == '' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == key_names + assert prefixes == [] + +def test_bucket_list_prefix_not_exist(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='d') + assert response['Prefix'] == 'd' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_not_exist(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='d') + assert response['Prefix'] == 'd' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +def test_bucket_list_prefix_unreadable(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Prefix='\x0a') + assert response['Prefix'] == '\x0a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_unreadable(): + key_names = ['foo/bar', 'foo/baz', 'quux'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Prefix='\x0a') + assert response['Prefix'] == '\x0a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +def test_bucket_list_prefix_delimiter_basic(): + key_names = ['foo/bar', 'foo/baz/xyzzy', 'quux/thud', 'asdf'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='/', Prefix='foo/') + assert response['Prefix'] == 'foo/' + assert response['Delimiter'] == '/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['foo/bar'] + assert prefixes == ['foo/baz/'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_delimiter_basic(): + key_names = ['foo/bar', 'foo/baz/xyzzy', 'quux/thud', 'asdf'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='/', Prefix='foo/') + assert response['Prefix'] == 'foo/' + assert response['Delimiter'] == '/' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['foo/bar'] + assert prefixes == ['foo/baz/'] + +def test_bucket_list_prefix_delimiter_alt(): + key_names = ['bar', 'bazar', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='a', Prefix='ba') + assert response['Prefix'] == 'ba' + assert response['Delimiter'] == 'a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['bar'] + assert prefixes == ['baza'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_delimiter_alt(): + key_names = ['bar', 'bazar', 'cab', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='a', Prefix='ba') + assert response['Prefix'] == 'ba' + assert response['Delimiter'] == 'a' + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['bar'] + assert prefixes == ['baza'] + +def test_bucket_list_prefix_delimiter_prefix_not_exist(): + key_names = ['b/a/r', 'b/a/c', 'b/a/g', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='d', Prefix='/') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_delimiter_prefix_not_exist(): + key_names = ['b/a/r', 'b/a/c', 'b/a/g', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='d', Prefix='/') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +def test_bucket_list_prefix_delimiter_delimiter_not_exist(): + key_names = ['b/a/c', 'b/a/g', 'b/a/r', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='z', Prefix='b') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['b/a/c', 'b/a/g', 'b/a/r'] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_delimiter_delimiter_not_exist(): + key_names = ['b/a/c', 'b/a/g', 'b/a/r', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='z', Prefix='b') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == ['b/a/c', 'b/a/g', 'b/a/r'] + assert prefixes == [] + +def test_bucket_list_prefix_delimiter_prefix_delimiter_not_exist(): + key_names = ['b/a/c', 'b/a/g', 'b/a/r', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Delimiter='z', Prefix='y') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_prefix_delimiter_prefix_delimiter_not_exist(): + key_names = ['b/a/c', 'b/a/g', 'b/a/r', 'g'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, Delimiter='z', Prefix='y') + + keys = _get_keys(response) + prefixes = _get_prefixes(response) + assert keys == [] + assert prefixes == [] + +@pytest.mark.fails_on_dbstore +def test_bucket_list_maxkeys_one(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, MaxKeys=1) + assert response['IsTruncated'] == True + + keys = _get_keys(response) + assert keys == key_names[0:1] + + response = client.list_objects(Bucket=bucket_name, Marker=key_names[0]) + assert response['IsTruncated'] == False + + keys = _get_keys(response) + assert keys == key_names[1:] + +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_maxkeys_one(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=1) + assert response['IsTruncated'] == True + + keys = _get_keys(response) + assert keys == key_names[0:1] + + response = client.list_objects_v2(Bucket=bucket_name, StartAfter=key_names[0]) + assert response['IsTruncated'] == False + + keys = _get_keys(response) + assert keys == key_names[1:] + +def test_bucket_list_maxkeys_zero(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, MaxKeys=0) + + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_maxkeys_zero(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=0) + + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == [] + +def test_bucket_list_maxkeys_none(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name) + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + assert response['MaxKeys'] == 1000 + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_maxkeys_none(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name) + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + assert response['MaxKeys'] == 1000 + +def get_http_response_body(**kwargs): + global http_response_body + http_response_body = kwargs['http_response'].__dict__['_content'] + +def parseXmlToJson(xml): + response = {} + + for child in list(xml): + if len(list(child)) > 0: + response[child.tag] = parseXmlToJson(child) + else: + response[child.tag] = child.text or '' + + # one-liner equivalent + # response[child.tag] = parseXmlToJson(child) if len(list(child)) > 0 else child.text or '' + + return response + +@pytest.mark.fails_on_aws +def test_account_usage(): + # boto3.set_stream_logger(name='botocore') + client = get_client() + # adds the unordered query parameter + def add_usage(**kwargs): + kwargs['params']['url'] += "?usage" + client.meta.events.register('before-call.s3.ListBuckets', add_usage) + client.meta.events.register('after-call.s3.ListBuckets', get_http_response_body) + client.list_buckets() + xml = ET.fromstring(http_response_body.decode('utf-8')) + parsed = parseXmlToJson(xml) + summary = parsed['Summary'] + assert summary['QuotaMaxBytes'] == '-1' + assert summary['QuotaMaxBuckets'] == '1000' + assert summary['QuotaMaxObjCount'] == '-1' + assert summary['QuotaMaxBytesPerBucket'] == '-1' + assert summary['QuotaMaxObjCountPerBucket'] == '-1' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_head_bucket_usage(): + client = get_client() + bucket_name = _create_objects(keys=['foo']) + + def add_read_stats_param(request, **kwargs): + request.params['read-stats'] = 'true' + + client.meta.events.register('request-created.s3.HeadBucket', add_read_stats_param) + client.meta.events.register('after-call.s3.HeadBucket', get_http_response) + client.head_bucket(Bucket=bucket_name) + hdrs = http_response['headers'] + assert hdrs['X-RGW-Object-Count'] == '1' + assert hdrs['X-RGW-Bytes-Used'] == '3' + assert hdrs['X-RGW-Quota-Max-Buckets'] == '1000' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_bucket_list_unordered(): + # boto3.set_stream_logger(name='botocore') + keys_in = ['ado', 'bot', 'cob', 'dog', 'emu', 'fez', 'gnu', 'hex', + 'abc/ink', 'abc/jet', 'abc/kin', 'abc/lax', 'abc/mux', + 'def/nim', 'def/owl', 'def/pie', 'def/qed', 'def/rye', + 'ghi/sew', 'ghi/tor', 'ghi/uke', 'ghi/via', 'ghi/wit', + 'xix', 'yak', 'zoo'] + bucket_name = _create_objects(keys=keys_in) + client = get_client() + + # adds the unordered query parameter + def add_unordered(**kwargs): + kwargs['params']['url'] += "&allow-unordered=true" + client.meta.events.register('before-call.s3.ListObjects', add_unordered) + + # test simple retrieval + response = client.list_objects(Bucket=bucket_name, MaxKeys=1000) + unordered_keys_out = _get_keys(response) + assert len(keys_in) == len(unordered_keys_out) + assert keys_in.sort() == unordered_keys_out.sort() + + # test retrieval with prefix + response = client.list_objects(Bucket=bucket_name, + MaxKeys=1000, + Prefix="abc/") + unordered_keys_out = _get_keys(response) + assert 5 == len(unordered_keys_out) + + # test incremental retrieval with marker + response = client.list_objects(Bucket=bucket_name, MaxKeys=6) + unordered_keys_out = _get_keys(response) + assert 6 == len(unordered_keys_out) + + # now get the next bunch + response = client.list_objects(Bucket=bucket_name, + MaxKeys=6, + Marker=unordered_keys_out[-1]) + unordered_keys_out2 = _get_keys(response) + assert 6 == len(unordered_keys_out2) + + # make sure there's no overlap between the incremental retrievals + intersect = set(unordered_keys_out).intersection(unordered_keys_out2) + assert 0 == len(intersect) + + # verify that unordered used with delimiter results in error + e = assert_raises(ClientError, + client.list_objects, Bucket=bucket_name, Delimiter="/") + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.fails_on_aws +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_unordered(): + # boto3.set_stream_logger(name='botocore') + keys_in = ['ado', 'bot', 'cob', 'dog', 'emu', 'fez', 'gnu', 'hex', + 'abc/ink', 'abc/jet', 'abc/kin', 'abc/lax', 'abc/mux', + 'def/nim', 'def/owl', 'def/pie', 'def/qed', 'def/rye', + 'ghi/sew', 'ghi/tor', 'ghi/uke', 'ghi/via', 'ghi/wit', + 'xix', 'yak', 'zoo'] + bucket_name = _create_objects(keys=keys_in) + client = get_client() + + # adds the unordered query parameter + def add_unordered(**kwargs): + kwargs['params']['url'] += "&allow-unordered=true" + client.meta.events.register('before-call.s3.ListObjects', add_unordered) + + # test simple retrieval + response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=1000) + unordered_keys_out = _get_keys(response) + assert len(keys_in) == len(unordered_keys_out) + assert keys_in.sort() == unordered_keys_out.sort() + + # test retrieval with prefix + response = client.list_objects_v2(Bucket=bucket_name, + MaxKeys=1000, + Prefix="abc/") + unordered_keys_out = _get_keys(response) + assert 5 == len(unordered_keys_out) + + # test incremental retrieval with marker + response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=6) + unordered_keys_out = _get_keys(response) + assert 6 == len(unordered_keys_out) + + # now get the next bunch + response = client.list_objects_v2(Bucket=bucket_name, + MaxKeys=6, + StartAfter=unordered_keys_out[-1]) + unordered_keys_out2 = _get_keys(response) + assert 6 == len(unordered_keys_out2) + + # make sure there's no overlap between the incremental retrievals + intersect = set(unordered_keys_out).intersection(unordered_keys_out2) + assert 0 == len(intersect) + + #pdb.set_trace() + # verify that unordered used with delimiter results in error + e = assert_raises(ClientError, + client.list_objects, Bucket=bucket_name, Delimiter="/") + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + + +def test_bucket_list_maxkeys_invalid(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + # adds invalid max keys to url + # before list_objects is called + def add_invalid_maxkeys(**kwargs): + kwargs['params']['url'] += "&max-keys=blah" + client.meta.events.register('before-call.s3.ListObjects', add_invalid_maxkeys) + + e = assert_raises(ClientError, client.list_objects, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + + + +def test_bucket_list_marker_none(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name) + assert response['Marker'] == '' + + +def test_bucket_list_marker_empty(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Marker='') + assert response['Marker'] == '' + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_continuationtoken_empty(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, ContinuationToken='') + assert response['ContinuationToken'] == '' + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_continuationtoken(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response1 = client.list_objects_v2(Bucket=bucket_name, MaxKeys=1) + next_continuation_token = response1['NextContinuationToken'] + + response2 = client.list_objects_v2(Bucket=bucket_name, ContinuationToken=next_continuation_token) + assert response2['ContinuationToken'] == next_continuation_token + assert response2['IsTruncated'] == False + key_names2 = ['baz', 'foo', 'quxx'] + keys = _get_keys(response2) + assert keys == key_names2 + +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_bucket_listv2_both_continuationtoken_startafter(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response1 = client.list_objects_v2(Bucket=bucket_name, StartAfter='bar', MaxKeys=1) + next_continuation_token = response1['NextContinuationToken'] + + response2 = client.list_objects_v2(Bucket=bucket_name, StartAfter='bar', ContinuationToken=next_continuation_token) + assert response2['ContinuationToken'] == next_continuation_token + assert response2['StartAfter'] == 'bar' + assert response2['IsTruncated'] == False + key_names2 = ['foo', 'quxx'] + keys = _get_keys(response2) + assert keys == key_names2 + +def test_bucket_list_marker_unreadable(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Marker='\x0a') + assert response['Marker'] == '\x0a' + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_startafter_unreadable(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, StartAfter='\x0a') + assert response['StartAfter'] == '\x0a' + assert response['IsTruncated'] == False + keys = _get_keys(response) + assert keys == key_names + +def test_bucket_list_marker_not_in_list(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Marker='blah') + assert response['Marker'] == 'blah' + keys = _get_keys(response) + assert keys == [ 'foo','quxx'] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_startafter_not_in_list(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, StartAfter='blah') + assert response['StartAfter'] == 'blah' + keys = _get_keys(response) + assert keys == ['foo', 'quxx'] + +def test_bucket_list_marker_after_list(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects(Bucket=bucket_name, Marker='zzz') + assert response['Marker'] == 'zzz' + keys = _get_keys(response) + assert response['IsTruncated'] == False + assert keys == [] + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_startafter_after_list(): + key_names = ['bar', 'baz', 'foo', 'quxx'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + response = client.list_objects_v2(Bucket=bucket_name, StartAfter='zzz') + assert response['StartAfter'] == 'zzz' + keys = _get_keys(response) + assert response['IsTruncated'] == False + assert keys == [] + +def _compare_dates(datetime1, datetime2): + """ + changes ms from datetime1 to 0, compares it to datetime2 + """ + # both times are in datetime format but datetime1 has + # microseconds and datetime2 does not + datetime1 = datetime1.replace(microsecond=0) + assert datetime1 == datetime2 + +@pytest.mark.fails_on_dbstore +def test_bucket_list_return_data(): + key_names = ['bar', 'baz', 'foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + data = {} + for key_name in key_names: + obj_response = client.head_object(Bucket=bucket_name, Key=key_name) + acl_response = client.get_object_acl(Bucket=bucket_name, Key=key_name) + data.update({ + key_name: { + 'DisplayName': acl_response['Owner']['DisplayName'], + 'ID': acl_response['Owner']['ID'], + 'ETag': obj_response['ETag'], + 'LastModified': obj_response['LastModified'], + 'ContentLength': obj_response['ContentLength'], + } + }) + + response = client.list_objects(Bucket=bucket_name) + objs_list = response['Contents'] + for obj in objs_list: + key_name = obj['Key'] + key_data = data[key_name] + assert obj['ETag'] == key_data['ETag'] + assert obj['Size'] == key_data['ContentLength'] + assert obj['Owner']['DisplayName'] == key_data['DisplayName'] + assert obj['Owner']['ID'] == key_data['ID'] + _compare_dates(obj['LastModified'],key_data['LastModified']) + + +@pytest.mark.fails_on_dbstore +def test_bucket_list_return_data_versioning(): + bucket_name = get_new_bucket() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + key_names = ['bar', 'baz', 'foo'] + bucket_name = _create_objects(bucket_name=bucket_name,keys=key_names) + + client = get_client() + data = {} + + for key_name in key_names: + obj_response = client.head_object(Bucket=bucket_name, Key=key_name) + acl_response = client.get_object_acl(Bucket=bucket_name, Key=key_name) + data.update({ + key_name: { + 'ID': acl_response['Owner']['ID'], + 'DisplayName': acl_response['Owner']['DisplayName'], + 'ETag': obj_response['ETag'], + 'LastModified': obj_response['LastModified'], + 'ContentLength': obj_response['ContentLength'], + 'VersionId': obj_response['VersionId'] + } + }) + + response = client.list_object_versions(Bucket=bucket_name) + objs_list = response['Versions'] + + for obj in objs_list: + key_name = obj['Key'] + key_data = data[key_name] + assert obj['Owner']['DisplayName'] == key_data['DisplayName'] + assert obj['ETag'] == key_data['ETag'] + assert obj['Size'] == key_data['ContentLength'] + assert obj['Owner']['ID'] == key_data['ID'] + assert obj['VersionId'] == key_data['VersionId'] + _compare_dates(obj['LastModified'],key_data['LastModified']) + +def test_bucket_list_objects_anonymous(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + + unauthenticated_client = get_unauthenticated_client() + unauthenticated_client.list_objects(Bucket=bucket_name) + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_objects_anonymous(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + + unauthenticated_client = get_unauthenticated_client() + unauthenticated_client.list_objects_v2(Bucket=bucket_name) + +def test_bucket_list_objects_anonymous_fail(): + bucket_name = get_new_bucket() + + unauthenticated_client = get_unauthenticated_client() + e = assert_raises(ClientError, unauthenticated_client.list_objects, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.list_objects_v2 +def test_bucket_listv2_objects_anonymous_fail(): + bucket_name = get_new_bucket() + + unauthenticated_client = get_unauthenticated_client() + e = assert_raises(ClientError, unauthenticated_client.list_objects_v2, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +def test_bucket_notexist(): + bucket_name = get_new_bucket_name() + client = get_client() + + e = assert_raises(ClientError, client.list_objects, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +@pytest.mark.list_objects_v2 +def test_bucketv2_notexist(): + bucket_name = get_new_bucket_name() + client = get_client() + + e = assert_raises(ClientError, client.list_objects_v2, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_bucket_delete_notexist(): + bucket_name = get_new_bucket_name() + client = get_client() + + e = assert_raises(ClientError, client.delete_bucket, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_bucket_delete_nonempty(): + key_names = ['foo'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + e = assert_raises(ClientError, client.delete_bucket, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'BucketNotEmpty' + +def _do_set_bucket_canned_acl(client, bucket_name, canned_acl, i, results): + try: + client.put_bucket_acl(ACL=canned_acl, Bucket=bucket_name) + results[i] = True + except: + results[i] = False + +def _do_set_bucket_canned_acl_concurrent(client, bucket_name, canned_acl, num, results): + t = [] + for i in range(num): + thr = threading.Thread(target = _do_set_bucket_canned_acl, args=(client, bucket_name, canned_acl, i, results)) + thr.start() + t.append(thr) + return t + +def _do_wait_completion(t): + for thr in t: + thr.join() + +def test_bucket_concurrent_set_canned_acl(): + bucket_name = get_new_bucket() + client = get_client() + + num_threads = 50 # boto2 retry defaults to 5 so we need a thread to fail at least 5 times + # this seems like a large enough number to get through retry (if bug + # exists) + results = [None] * num_threads + + t = _do_set_bucket_canned_acl_concurrent(client, bucket_name, 'public-read', num_threads, results) + _do_wait_completion(t) + + for r in results: + assert r == True + +def test_object_write_to_nonexist_bucket(): + key_names = ['foo'] + bucket_name = 'whatchutalkinboutwillis' + client = get_client() + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo', Body='foo') + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + + +def _ev_add_te_header(request, **kwargs): + request.headers.add_header('Transfer-Encoding', 'chunked') + +def test_object_write_with_chunked_transfer_encoding(): + bucket_name = get_new_bucket() + client = get_client() + + client.meta.events.register_first('before-sign.*.*', _ev_add_te_header) + response = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +def test_bucket_create_delete(): + bucket_name = get_new_bucket() + client = get_client() + client.delete_bucket(Bucket=bucket_name) + + e = assert_raises(ClientError, client.delete_bucket, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_object_read_not_exist(): + bucket_name = get_new_bucket() + client = get_client() + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='bar') + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +http_response = None + +def get_http_response(**kwargs): + global http_response + http_response = kwargs['http_response'].__dict__ + +@pytest.mark.fails_on_dbstore +def test_object_requestid_matches_header_on_error(): + bucket_name = get_new_bucket() + client = get_client() + + # get http response after failed request + client.meta.events.register('after-call.s3.GetObject', get_http_response) + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='bar') + + response_body = http_response['_content'] + resp_body_xml = ET.fromstring(response_body) + request_id = resp_body_xml.find('.//RequestId').text + + assert request_id is not None + assert request_id == e.response['ResponseMetadata']['RequestId'] + +def _make_objs_dict(key_names): + objs_list = [] + for key in key_names: + obj_dict = {'Key': key} + objs_list.append(obj_dict) + objs_dict = {'Objects': objs_list} + return objs_dict + +def test_versioning_concurrent_multi_object_delete(): + num_objects = 5 + num_versions_per_object = 3 + total_num_objects_in_the_bucket = num_objects * num_versions_per_object + num_threads = 5 + bucket_name = get_new_bucket() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key_names = ["key_{:d}".format(x) for x in range(num_objects)] + for _ in range(num_versions_per_object): + bucket = _create_objects(bucket_name=bucket_name, keys=key_names) + assert bucket == bucket_name + + client = get_client() + versions = client.list_object_versions(Bucket=bucket_name)['Versions'] + assert len(versions) == total_num_objects_in_the_bucket + objs_dict = {'Objects': [dict((k, v[k]) for k in ["Key", "VersionId"]) for v in versions]} + results = [None] * num_threads + + def do_request(n): + results[n] = client.delete_objects(Bucket=bucket_name, Delete=objs_dict) + + t = [] + for i in range(num_threads): + thr = threading.Thread(target = do_request, args=[i]) + thr.start() + t.append(thr) + _do_wait_completion(t) + + for response in results: + assert len(response['Deleted']) == total_num_objects_in_the_bucket + assert 'Errors' not in response + + response_list_objects = client.list_objects(Bucket=bucket_name) + assert 'Contents' not in response_list_objects + response_list_versions = client.list_object_versions(Bucket=bucket_name) + assert 'Versions' not in response_list_versions and 'DeleteMarkers' not in response_list_versions + +def test_multi_object_delete(): + key_names = ['key0', 'key1', 'key2'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + response = client.list_objects(Bucket=bucket_name) + assert len(response['Contents']) == 3 + + objs_dict = _make_objs_dict(key_names=key_names) + response = client.delete_objects(Bucket=bucket_name, Delete=objs_dict) + + assert len(response['Deleted']) == 3 + assert 'Errors' not in response + response = client.list_objects(Bucket=bucket_name) + assert 'Contents' not in response + + response = client.delete_objects(Bucket=bucket_name, Delete=objs_dict) + assert len(response['Deleted']) == 3 + assert 'Errors' not in response + response = client.list_objects(Bucket=bucket_name) + assert 'Contents' not in response + +@pytest.mark.list_objects_v2 +def test_expected_bucket_owner(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read-write') + client.list_objects(Bucket=bucket_name) + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + unauthenticated_client = get_unauthenticated_client() + incorrect_expected_owner = get_main_user_id() + 'foo' + + e = assert_raises(ClientError, unauthenticated_client.list_objects, Bucket=bucket_name, ExpectedBucketOwner=incorrect_expected_owner) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + e = assert_raises(ClientError, unauthenticated_client.put_object, Bucket=bucket_name, Key='bar', Body='coffee', ExpectedBucketOwner=incorrect_expected_owner) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.list_objects_v2 +def test_multi_objectv2_delete(): + key_names = ['key0', 'key1', 'key2'] + bucket_name = _create_objects(keys=key_names) + client = get_client() + response = client.list_objects_v2(Bucket=bucket_name) + assert len(response['Contents']) == 3 + + objs_dict = _make_objs_dict(key_names=key_names) + response = client.delete_objects(Bucket=bucket_name, Delete=objs_dict) + + assert len(response['Deleted']) == 3 + assert 'Errors' not in response + response = client.list_objects_v2(Bucket=bucket_name) + assert 'Contents' not in response + + response = client.delete_objects(Bucket=bucket_name, Delete=objs_dict) + assert len(response['Deleted']) == 3 + assert 'Errors' not in response + response = client.list_objects_v2(Bucket=bucket_name) + assert 'Contents' not in response + +def test_multi_object_delete_key_limit(): + key_names = [f"key-{i}" for i in range(1001)] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + paginator = client.get_paginator('list_objects') + pages = paginator.paginate(Bucket=bucket_name) + numKeys = 0 + for page in pages: + numKeys += len(page['Contents']) + assert numKeys == 1001 + + objs_dict = _make_objs_dict(key_names=key_names) + e = assert_raises(ClientError,client.delete_objects,Bucket=bucket_name,Delete=objs_dict) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +def test_multi_objectv2_delete_key_limit(): + key_names = [f"key-{i}" for i in range(1001)] + bucket_name = _create_objects(keys=key_names) + client = get_client() + + paginator = client.get_paginator('list_objects_v2') + pages = paginator.paginate(Bucket=bucket_name) + numKeys = 0 + for page in pages: + numKeys += len(page['Contents']) + assert numKeys == 1001 + + objs_dict = _make_objs_dict(key_names=key_names) + e = assert_raises(ClientError,client.delete_objects,Bucket=bucket_name,Delete=objs_dict) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +def test_object_head_zero_bytes(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='') + + response = client.head_object(Bucket=bucket_name, Key='foo') + assert response['ContentLength'] == 0 + +def test_object_write_check_etag(): + bucket_name = get_new_bucket() + client = get_client() + response = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['ETag'] == '"37b51d194a7513e45b56f6524f2d51f2"' + +def test_object_write_cache_control(): + bucket_name = get_new_bucket() + client = get_client() + cache_control = 'public, max-age=14400' + client.put_object(Bucket=bucket_name, Key='foo', Body='bar', CacheControl=cache_control) + + response = client.head_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPHeaders']['cache-control'] == cache_control + +def test_object_write_expires(): + bucket_name = get_new_bucket() + client = get_client() + + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + client.put_object(Bucket=bucket_name, Key='foo', Body='bar', Expires=expires) + + response = client.head_object(Bucket=bucket_name, Key='foo') + _compare_dates(expires, response['Expires']) + +def _get_body(response): + body = response['Body'] + got = body.read() + if type(got) is bytes: + got = got.decode() + return got + +def test_object_write_read_update_read_delete(): + bucket_name = get_new_bucket() + client = get_client() + + # Write + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + # Read + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + # Update + client.put_object(Bucket=bucket_name, Key='foo', Body='soup') + # Read + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'soup' + # Delete + client.delete_object(Bucket=bucket_name, Key='foo') + +def _set_get_metadata(metadata, bucket_name=None): + """ + create a new bucket new or use an existing + name to create an object that bucket, + set the meta1 property to a specified, value, + and then re-read and return that property + """ + if bucket_name is None: + bucket_name = get_new_bucket() + + client = get_client() + metadata_dict = {'meta1': metadata} + client.put_object(Bucket=bucket_name, Key='foo', Body='bar', Metadata=metadata_dict) + + response = client.get_object(Bucket=bucket_name, Key='foo') + return response['Metadata']['meta1'] + +def test_object_set_get_metadata_none_to_good(): + got = _set_get_metadata('mymeta') + assert got == 'mymeta' + +def test_object_set_get_metadata_none_to_empty(): + got = _set_get_metadata('') + assert got == '' + +def test_object_set_get_metadata_overwrite_to_empty(): + bucket_name = get_new_bucket() + got = _set_get_metadata('oldmeta', bucket_name) + assert got == 'oldmeta' + got = _set_get_metadata('', bucket_name) + assert got == '' + +# TODO: the decoding of this unicode metadata is not happening properly for unknown reasons +@pytest.mark.fails_on_rgw +def test_object_set_get_unicode_metadata(): + bucket_name = get_new_bucket() + client = get_client() + + def set_unicode_metadata(**kwargs): + kwargs['params']['headers']['x-amz-meta-meta1'] = u"Hello World\xe9" + + client.meta.events.register('before-call.s3.PutObject', set_unicode_metadata) + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + got = response['Metadata']['meta1'] + print(got) + print(u"Hello World\xe9") + assert got == u"Hello World\xe9" + +def _set_get_metadata_unreadable(metadata, bucket_name=None): + """ + set and then read back a meta-data value (which presumably + includes some interesting characters), and return a list + containing the stored value AND the encoding with which it + was returned. + + This should return a 400 bad request because the webserver + rejects the request. + """ + bucket_name = get_new_bucket() + client = get_client() + metadata_dict = {'meta1': metadata} + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='bar', Metadata=metadata_dict) + return e + +def test_object_metadata_replaced_on_put(): + bucket_name = get_new_bucket() + client = get_client() + metadata_dict = {'meta1': 'bar'} + client.put_object(Bucket=bucket_name, Key='foo', Body='bar', Metadata=metadata_dict) + + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + got = response['Metadata'] + assert got == {} + +def test_object_write_file(): + bucket_name = get_new_bucket() + client = get_client() + data_str = 'bar' + data = bytes(data_str, 'utf-8') + client.put_object(Bucket=bucket_name, Key='foo', Body=data) + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +def _get_post_url(bucket_name): + endpoint = get_config_endpoint() + return '{endpoint}/{bucket_name}'.format(endpoint=endpoint, bucket_name=bucket_name) + +def test_post_object_anonymous_request(): + bucket_name = get_new_bucket_name() + client = get_client() + url = _get_post_url(bucket_name) + payload = OrderedDict([("key" , "foo.txt"),("acl" , "public-read"),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + +def test_post_object_authenticated_request(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + +def test_post_object_authenticated_no_content_type(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key="foo.txt") + body = _get_body(response) + assert body == 'bar' + +def test_post_object_authenticated_request_bad_access_key(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , 'foo'),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_set_success_code(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + url = _get_post_url(bucket_name) + payload = OrderedDict([("key" , "foo.txt"),("acl" , "public-read"),\ + ("success_action_status" , "201"),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 201 + message = ET.fromstring(r.content).find('Key') + assert message.text == 'foo.txt' + +def test_post_object_set_invalid_success_code(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + url = _get_post_url(bucket_name) + payload = OrderedDict([("key" , "foo.txt"),("acl" , "public-read"),\ + ("success_action_status" , "404"),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + content = r.content.decode() + assert content == '' + +def test_post_object_upload_larger_than_chunk(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 5*1024*1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + foo_string = 'foo' * 1024*1024 + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', foo_string)]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == foo_string + +def test_post_object_set_key_from_filename(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "${filename}"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('foo.txt', 'bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + +def test_post_object_ignored_header(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),("x-ignore-foo" , "bar"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + +def test_post_object_case_insensitive_condition_fields(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bUcKeT": bucket_name},\ + ["StArTs-WiTh", "$KeY", "foo"],\ + {"AcL": "private"},\ + ["StArTs-WiTh", "$CoNtEnT-TyPe", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + foo_string = 'foo' * 1024*1024 + + payload = OrderedDict([ ("kEy" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("aCl" , "private"),("signature" , signature),("pOLICy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + +def test_post_object_escaped_field_values(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , r"\$foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key=r'\$foo.txt') + body = _get_body(response) + assert body == 'bar' + +def test_post_object_success_redirect_action(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + url = _get_post_url(bucket_name) + redirect_url = _get_post_url(bucket_name) + + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["eq", "$success_action_redirect", redirect_url],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),("success_action_redirect" , redirect_url),\ + ('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 200 + url = r.url + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + assert url == '{rurl}?bucket={bucket}&key={key}&etag=%22{etag}%22'.format(\ + rurl = redirect_url, bucket = bucket_name, key = 'foo.txt', etag = response['ETag'].strip('"')) + +def test_post_object_invalid_signature(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest())[::-1] + + payload = OrderedDict([ ("key" , r"\$foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_invalid_access_key(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , r"\$foo.txt"),("AWSAccessKeyId" , aws_access_key_id[::-1]),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_invalid_date_format(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": str(expires),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , r"\$foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_no_key_specified(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_missing_signature(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_missing_policy_condition(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + ["starts-with", "$key", r"\$foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_user_specified_header(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ["starts-with", "$x-amz-meta-foo", "bar"] + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('x-amz-meta-foo' , 'barclamp'),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + assert response['Metadata']['foo'] == 'barclamp' + +def test_post_object_request_missing_policy_specified_field(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ["starts-with", "$x-amz-meta-foo", "bar"] + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_condition_is_case_sensitive(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "CONDITIONS": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_expires_is_case_sensitive(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"EXPIRATION": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_expired_policy(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=-6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key", "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_wrong_bucket(): + bucket_name = get_new_bucket() + client = get_client() + + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024]\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "${filename}"),('bucket', bucket_name),\ + ("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('foo.txt', 'bar'))]) + + bad_bucket_name = get_new_bucket() + url = _get_post_url(bad_bucket_name) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_invalid_request_field_value(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ["eq", "$x-amz-meta-foo", ""] + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('x-amz-meta-foo' , 'barclamp'),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 403 + +def test_post_object_missing_expires_condition(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 1024],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_missing_conditions_list(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ")} + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_upload_size_limit_exceeded(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0, 0],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_missing_content_length_argument(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 0],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_invalid_content_length_argument(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", -1, 0],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_upload_size_below_minimum(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", 512, 1000],\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_post_object_upload_size_rgw_chunk_size_bug(): + # Test for https://tracker.ceph.com/issues/58627 + # TODO: if this value is different in Teuthology runs, this would need tuning + # https://github.com/ceph/ceph/blob/main/qa/suites/rgw/verify/striping%24/stripe-greater-than-chunk.yaml + _rgw_max_chunk_size = 4 * 2**20 # 4MiB + min_size = _rgw_max_chunk_size + max_size = _rgw_max_chunk_size * 3 + # [(chunk),(small)] + test_payload_size = _rgw_max_chunk_size + 200 # extra bit to push it over the chunk boundary + # it should be valid when we run this test! + assert test_payload_size > min_size + assert test_payload_size < max_size + + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", min_size, max_size],\ + ]\ + } + + test_payload = 'x' * test_payload_size + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', (test_payload))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + +def test_post_object_empty_conditions(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + { }\ + ]\ + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + +def test_get_object_ifmatch_good(): + bucket_name = get_new_bucket() + client = get_client() + response = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + etag = response['ETag'] + + response = client.get_object(Bucket=bucket_name, Key='foo', IfMatch=etag) + body = _get_body(response) + assert body == 'bar' + +def test_get_object_ifmatch_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo', IfMatch='"ABCORZ"') + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + +def test_get_object_ifnonematch_good(): + bucket_name = get_new_bucket() + client = get_client() + response = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + etag = response['ETag'] + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo', IfNoneMatch=etag) + status, error_code = _get_status_and_error_code(e.response) + assert status == 304 + assert e.response['Error']['Message'] == 'Not Modified' + assert e.response['ResponseMetadata']['HTTPHeaders']['etag'] == etag + +def test_get_object_ifnonematch_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo', IfNoneMatch='ABCORZ') + body = _get_body(response) + assert body == 'bar' + +def test_get_object_ifmodifiedsince_good(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo', IfModifiedSince='Sat, 29 Oct 1994 19:43:31 GMT') + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.fails_on_dbstore +def test_get_object_ifmodifiedsince_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object(Bucket=bucket_name, Key='foo') + etag = response['ETag'] + last_modified = str(response['LastModified']) + + last_modified = last_modified.split('+')[0] + mtime = datetime.datetime.strptime(last_modified, '%Y-%m-%d %H:%M:%S') + + after = mtime + datetime.timedelta(seconds=1) + after_str = time.strftime("%a, %d %b %Y %H:%M:%S GMT", after.timetuple()) + + time.sleep(1) + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo', IfModifiedSince=after_str) + status, error_code = _get_status_and_error_code(e.response) + assert status == 304 + assert e.response['Error']['Message'] == 'Not Modified' + assert e.response['ResponseMetadata']['HTTPHeaders']['etag'] == etag + +@pytest.mark.fails_on_dbstore +def test_get_object_ifunmodifiedsince_good(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo', IfUnmodifiedSince='Sat, 29 Oct 1994 19:43:31 GMT') + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + +def test_get_object_ifunmodifiedsince_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo', IfUnmodifiedSince='Sat, 29 Oct 2100 19:43:31 GMT') + body = _get_body(response) + assert body == 'bar' + + +@pytest.mark.fails_on_aws +def test_put_object_ifmatch_good(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + etag = response['ETag'].replace('"', '') + + # pass in custom header 'If-Match' before PutObject call + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': etag})) + client.meta.events.register('before-call.s3.PutObject', lf) + response = client.put_object(Bucket=bucket_name,Key='foo', Body='zar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'zar' + +@pytest.mark.fails_on_dbstore +def test_put_object_ifmatch_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + # pass in custom header 'If-Match' before PutObject call + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': '"ABCORZ"'})) + client.meta.events.register('before-call.s3.PutObject', lf) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo', Body='zar') + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.fails_on_aws +def test_put_object_ifmatch_overwrite_existed_good(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': '*'})) + client.meta.events.register('before-call.s3.PutObject', lf) + response = client.put_object(Bucket=bucket_name,Key='foo', Body='zar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'zar' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_put_object_ifmatch_nonexisted_failed(): + bucket_name = get_new_bucket() + client = get_client() + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': '*'})) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo', Body='bar') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +@pytest.mark.fails_on_aws +def test_put_object_ifnonmatch_good(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-None-Match': 'ABCORZ'})) + client.meta.events.register('before-call.s3.PutObject', lf) + response = client.put_object(Bucket=bucket_name,Key='foo', Body='zar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'zar' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_put_object_ifnonmatch_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + etag = response['ETag'].replace('"', '') + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-None-Match': etag})) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo', Body='zar') + + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.fails_on_aws +def test_put_object_ifnonmatch_nonexisted_good(): + bucket_name = get_new_bucket() + client = get_client() + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-None-Match': '*'})) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_put_object_ifnonmatch_overwrite_existed_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-None-Match': '*'})) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo', Body='zar') + + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +def _setup_bucket_object_acl(bucket_acl, object_acl, client=None): + """ + add a foo key, and specified key and bucket acls to + a (new or existing) bucket. + """ + if client is None: + client = get_client() + bucket_name = get_new_bucket_name() + client.create_bucket(ACL=bucket_acl, Bucket=bucket_name) + client.put_object(ACL=object_acl, Bucket=bucket_name, Key='foo') + + return bucket_name + +def _setup_bucket_acl(bucket_acl=None): + """ + set up a new bucket with specified acl + """ + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL=bucket_acl, Bucket=bucket_name) + + return bucket_name + +def test_object_raw_get(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + + unauthenticated_client = get_unauthenticated_client() + response = unauthenticated_client.get_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_raw_get_bucket_gone(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + + client.delete_object(Bucket=bucket_name, Key='foo') + client.delete_bucket(Bucket=bucket_name) + + unauthenticated_client = get_unauthenticated_client() + + e = assert_raises(ClientError, unauthenticated_client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_object_delete_key_bucket_gone(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + + client.delete_object(Bucket=bucket_name, Key='foo') + client.delete_bucket(Bucket=bucket_name) + + unauthenticated_client = get_unauthenticated_client() + + e = assert_raises(ClientError, unauthenticated_client.delete_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_object_raw_get_object_gone(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + + client.delete_object(Bucket=bucket_name, Key='foo') + + unauthenticated_client = get_unauthenticated_client() + + e = assert_raises(ClientError, unauthenticated_client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +def test_bucket_head(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.head_bucket(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_bucket_head_notexist(): + bucket_name = get_new_bucket_name() + client = get_client() + + e = assert_raises(ClientError, client.head_bucket, Bucket=bucket_name) + + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + # n.b., RGW does not send a response document for this operation, + # which seems consistent with + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html + #assert error_code == 'NoSuchKey' + +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_bucket_head_extended(): + bucket_name = get_new_bucket() + client = get_client() + + def add_read_stats_param(request, **kwargs): + request.params['read-stats'] = 'true' + client.meta.events.register('request-created.s3.HeadBucket', add_read_stats_param) + + response = client.head_bucket(Bucket=bucket_name) + assert int(response['ResponseMetadata']['HTTPHeaders']['x-rgw-object-count']) == 0 + assert int(response['ResponseMetadata']['HTTPHeaders']['x-rgw-bytes-used']) == 0 + + _create_objects(bucket_name=bucket_name, keys=['foo','bar','baz']) + response = client.head_bucket(Bucket=bucket_name) + + assert int(response['ResponseMetadata']['HTTPHeaders']['x-rgw-object-count']) == 3 + assert int(response['ResponseMetadata']['HTTPHeaders']['x-rgw-bytes-used']) == 9 + +def test_object_raw_get_bucket_acl(): + bucket_name = _setup_bucket_object_acl('private', 'public-read') + + unauthenticated_client = get_unauthenticated_client() + response = unauthenticated_client.get_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_raw_get_object_acl(): + bucket_name = _setup_bucket_object_acl('public-read', 'private') + + unauthenticated_client = get_unauthenticated_client() + e = assert_raises(ClientError, unauthenticated_client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +def test_object_put_acl_mtime(): + key = 'foo' + bucket_name = get_new_bucket() + # Enable versioning + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + client = get_client() + + content = 'foooz' + client.put_object(Bucket=bucket_name, Key=key, Body=content) + + obj_response = client.head_object(Bucket=bucket_name, Key=key) + create_mtime = obj_response['LastModified'] + + response = client.list_objects(Bucket=bucket_name) + obj_list = response['Contents'][0] + _compare_dates(obj_list['LastModified'],create_mtime) + + response = client.list_object_versions(Bucket=bucket_name) + obj_list = response['Versions'][0] + _compare_dates(obj_list['LastModified'],create_mtime) + + # set acl + time.sleep(2) + client.put_object_acl(ACL='private',Bucket=bucket_name, Key=key) + + # mtime should match with create mtime + obj_response = client.head_object(Bucket=bucket_name, Key=key) + _compare_dates(create_mtime,obj_response['LastModified']) + + response = client.list_objects(Bucket=bucket_name) + obj_list = response['Contents'][0] + _compare_dates(obj_list['LastModified'],create_mtime) + + response = client.list_object_versions(Bucket=bucket_name) + obj_list = response['Versions'][0] + _compare_dates(obj_list['LastModified'],create_mtime) + +def test_object_raw_authenticated(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + + client = get_client() + response = client.get_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_raw_response_headers(): + bucket_name = _setup_bucket_object_acl('private', 'private') + + client = get_client() + + response = client.get_object(Bucket=bucket_name, Key='foo', ResponseCacheControl='no-cache', ResponseContentDisposition='bla', ResponseContentEncoding='aaa', ResponseContentLanguage='esperanto', ResponseContentType='foo/bar', ResponseExpires='123') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == 'foo/bar' + assert response['ResponseMetadata']['HTTPHeaders']['content-disposition'] == 'bla' + assert response['ResponseMetadata']['HTTPHeaders']['content-language'] == 'esperanto' + assert response['ResponseMetadata']['HTTPHeaders']['content-encoding'] == 'aaa' + assert response['ResponseMetadata']['HTTPHeaders']['cache-control'] == 'no-cache' + +def test_object_raw_authenticated_bucket_acl(): + bucket_name = _setup_bucket_object_acl('private', 'public-read') + + client = get_client() + response = client.get_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_raw_authenticated_object_acl(): + bucket_name = _setup_bucket_object_acl('public-read', 'private') + + client = get_client() + response = client.get_object(Bucket=bucket_name, Key='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_raw_authenticated_bucket_gone(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + + client.delete_object(Bucket=bucket_name, Key='foo') + client.delete_bucket(Bucket=bucket_name) + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_object_raw_authenticated_object_gone(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + + client.delete_object(Bucket=bucket_name, Key='foo') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +def _test_object_raw_get_x_amz_expires_not_expired(client): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read', client=client) + params = {'Bucket': bucket_name, 'Key': 'foo'} + + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=100000, HttpMethod='GET') + + res = requests.options(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 400 + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 200 + +def test_object_raw_get_x_amz_expires_not_expired(): + _test_object_raw_get_x_amz_expires_not_expired(client=get_client()) + +def test_object_raw_get_x_amz_expires_not_expired_tenant(): + _test_object_raw_get_x_amz_expires_not_expired(client=get_tenant_client()) + +def test_object_raw_get_x_amz_expires_out_range_zero(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + params = {'Bucket': bucket_name, 'Key': 'foo'} + + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=0, HttpMethod='GET') + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 403 + +def test_object_raw_get_x_amz_expires_out_max_range(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + params = {'Bucket': bucket_name, 'Key': 'foo'} + + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=609901, HttpMethod='GET') + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 403 + +def test_object_raw_get_x_amz_expires_out_positive_range(): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read') + client = get_client() + params = {'Bucket': bucket_name, 'Key': 'foo'} + + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=-7, HttpMethod='GET') + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 403 + +def test_object_content_encoding_aws_chunked(): + client = get_client() + bucket = get_new_bucket(client) + key = 'encoding' + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='gzip') + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentEncoding'] == 'gzip' + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='deflate, gzip') + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentEncoding'] == 'deflate, gzip' + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='gzip, aws-chunked') + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentEncoding'] == 'gzip' + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='aws-chunked, gzip') + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentEncoding'] == 'gzip' + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='aws-chunked') + response = client.head_object(Bucket=bucket, Key=key) + assert 'ContentEncoding' not in response + + client.put_object(Bucket=bucket, Key=key, ContentEncoding='aws-chunked, aws-chunked') + response = client.head_object(Bucket=bucket, Key=key) + assert 'ContentEncoding' not in response + +def test_object_anon_put(): + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='foo') + + unauthenticated_client = get_unauthenticated_client() + + e = assert_raises(ClientError, unauthenticated_client.put_object, Bucket=bucket_name, Key='foo', Body='foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +def test_object_anon_put_write_access(): + bucket_name = _setup_bucket_acl('public-read-write') + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo') + + unauthenticated_client = get_unauthenticated_client() + + response = unauthenticated_client.put_object(Bucket=bucket_name, Key='foo', Body='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def test_object_put_authenticated(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.put_object(Bucket=bucket_name, Key='foo', Body='foo') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def _test_object_presigned_put_object_with_acl(client=None): + if client is None: + client = get_client() + + bucket_name = get_new_bucket(client) + key = 'foo' + + params = {'Bucket': bucket_name, 'Key': key, 'ACL': 'private'} + url = client.generate_presigned_url(ClientMethod='put_object', Params=params, HttpMethod='PUT') + + data = b'hello world' + headers = {'x-amz-acl': 'private'} + res = requests.put(url, data=data, headers=headers, verify=get_config_ssl_verify()) + assert res.status_code == 200 + + params = {'Bucket': bucket_name, 'Key': key} + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, HttpMethod='GET') + + res = requests.get(url, verify=get_config_ssl_verify()) + assert res.status_code == 200 + assert res.text == 'hello world' + +def test_object_presigned_put_object_with_acl(): + _test_object_presigned_put_object_with_acl( + client=get_client()) + +def test_object_presigned_put_object_with_acl_tenant(): + _test_object_presigned_put_object_with_acl( + client=get_tenant_client()) + +def test_object_raw_put_authenticated_expired(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo') + + params = {'Bucket': bucket_name, 'Key': 'foo'} + url = client.generate_presigned_url(ClientMethod='put_object', Params=params, ExpiresIn=-1000, HttpMethod='PUT') + + # params wouldn't take a 'Body' parameter so we're passing it in here + res = requests.put(url, data="foo", verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 403 + +def check_bad_bucket_name(bucket_name): + """ + Attempt to create a bucket with a specified name, and confirm + that the request fails because of an invalid bucket name. + """ + client = get_client() + e = assert_raises(ClientError, client.create_bucket, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidBucketName' + + +# AWS does not enforce all documented bucket restrictions. +# http://docs.amazonwebservices.com/AmazonS3/2006-03-01/dev/index.html?BucketRestrictions.html +@pytest.mark.fails_on_aws +# Breaks DNS with SubdomainCallingFormat +def test_bucket_create_naming_bad_starts_nonalpha(): + bucket_name = get_new_bucket_name() + check_bad_bucket_name('_' + bucket_name) + +def check_invalid_bucketname(invalid_name): + """ + Send a create bucket_request with an invalid bucket name + that will bypass the ParamValidationError that would be raised + if the invalid bucket name that was passed in normally. + This function returns the status and error code from the failure + """ + client = get_client() + valid_bucket_name = get_new_bucket_name() + def replace_bucketname_from_url(**kwargs): + url = kwargs['params']['url'] + new_url = url.replace(valid_bucket_name, invalid_name) + kwargs['params']['url'] = new_url + client.meta.events.register('before-call.s3.CreateBucket', replace_bucketname_from_url) + e = assert_raises(ClientError, client.create_bucket, Bucket=invalid_name) + status, error_code = _get_status_and_error_code(e.response) + return (status, error_code) + +def test_bucket_create_naming_bad_short_one(): + check_bad_bucket_name('a') + +def test_bucket_create_naming_bad_short_two(): + check_bad_bucket_name('aa') + +def check_good_bucket_name(name, _prefix=None): + """ + Attempt to create a bucket with a specified name + and (specified or default) prefix, returning the + results of that effort. + """ + # tests using this with the default prefix must *not* rely on + # being able to set the initial character, or exceed the max len + + # tests using this with a custom prefix are responsible for doing + # their own setup/teardown nukes, with their custom prefix; this + # should be very rare + if _prefix is None: + _prefix = get_prefix() + bucket_name = '{prefix}{name}'.format( + prefix=_prefix, + name=name, + ) + client = get_client() + response = client.create_bucket(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def _test_bucket_create_naming_good_long(length): + """ + Attempt to create a bucket whose name (including the + prefix) is of a specified length. + """ + # tests using this with the default prefix must *not* rely on + # being able to set the initial character, or exceed the max len + + # tests using this with a custom prefix are responsible for doing + # their own setup/teardown nukes, with their custom prefix; this + # should be very rare + prefix = get_new_bucket_name() + assert len(prefix) < 63 + num = length - len(prefix) + name=num*'a' + + bucket_name = '{prefix}{name}'.format( + prefix=prefix, + name=name, + ) + client = get_client() + response = client.create_bucket(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_good_long_60(): + _test_bucket_create_naming_good_long(60) + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_good_long_61(): + _test_bucket_create_naming_good_long(61) + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_good_long_62(): + _test_bucket_create_naming_good_long(62) + + +# Breaks DNS with SubdomainCallingFormat +def test_bucket_create_naming_good_long_63(): + _test_bucket_create_naming_good_long(63) + + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_list_long_name(): + prefix = get_new_bucket_name() + length = 61 + num = length - len(prefix) + name=num*'a' + + bucket_name = '{prefix}{name}'.format( + prefix=prefix, + name=name, + ) + bucket = get_new_bucket_resource(name=bucket_name) + is_empty = _bucket_is_empty(bucket) + assert is_empty == True + +# AWS does not enforce all documented bucket restrictions. +# http://docs.amazonwebservices.com/AmazonS3/2006-03-01/dev/index.html?BucketRestrictions.html +@pytest.mark.fails_on_aws +def test_bucket_create_naming_bad_ip(): + check_bad_bucket_name('192.168.5.123') + +# test_bucket_create_naming_dns_* are valid but not recommended +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_dns_underscore(): + invalid_bucketname = 'foo_bar' + status, error_code = check_invalid_bucketname(invalid_bucketname) + assert status == 400 + assert error_code == 'InvalidBucketName' + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +def test_bucket_create_naming_dns_long(): + prefix = get_prefix() + assert len(prefix) < 50 + num = 63 - len(prefix) + check_good_bucket_name(num * 'a') + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_dns_dash_at_end(): + invalid_bucketname = 'foo-' + status, error_code = check_invalid_bucketname(invalid_bucketname) + assert status == 400 + assert error_code == 'InvalidBucketName' + + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_dns_dot_dot(): + invalid_bucketname = 'foo..bar' + status, error_code = check_invalid_bucketname(invalid_bucketname) + assert status == 400 + assert error_code == 'InvalidBucketName' + + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_dns_dot_dash(): + invalid_bucketname = 'foo.-bar' + status, error_code = check_invalid_bucketname(invalid_bucketname) + assert status == 400 + assert error_code == 'InvalidBucketName' + + +# Breaks DNS with SubdomainCallingFormat +@pytest.mark.fails_on_aws +# Should now pass on AWS even though it has 'fails_on_aws' attr. +def test_bucket_create_naming_dns_dash_dot(): + invalid_bucketname = 'foo-.bar' + status, error_code = check_invalid_bucketname(invalid_bucketname) + assert status == 400 + assert error_code == 'InvalidBucketName' + +def test_bucket_create_exists(): + # aws-s3 default region allows recreation of buckets + # but all other regions fail with BucketAlreadyOwnedByYou. + bucket_name = get_new_bucket_name() + client = get_client() + + client.create_bucket(Bucket=bucket_name) + try: + response = client.create_bucket(Bucket=bucket_name) + except ClientError as e: + status, error_code = _get_status_and_error_code(e.response) + assert e.status == 409 + assert e.error_code == 'BucketAlreadyOwnedByYou' + +@pytest.mark.fails_on_dbstore +def test_bucket_get_location(): + location_constraint = get_main_api_name() + if not location_constraint: + pytest.skip('no api_name configured') + bucket_name = get_new_bucket_name() + client = get_client() + + client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={'LocationConstraint': location_constraint}) + + response = client.get_bucket_location(Bucket=bucket_name) + if location_constraint == "": + location_constraint = None + assert response['LocationConstraint'] == location_constraint + +@pytest.mark.fails_on_dbstore +def test_bucket_create_exists_nonowner(): + # Names are shared across a global namespace. As such, no two + # users can create a bucket with that same name. + bucket_name = get_new_bucket_name() + client = get_client() + + alt_client = get_alt_client() + + client.create_bucket(Bucket=bucket_name) + e = assert_raises(ClientError, alt_client.create_bucket, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'BucketAlreadyExists' + +@pytest.mark.fails_on_dbstore +def test_bucket_recreate_overwrite_acl(): + bucket_name = get_new_bucket_name() + client = get_client() + + client.create_bucket(Bucket=bucket_name, ACL='public-read') + e = assert_raises(ClientError, client.create_bucket, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'BucketAlreadyExists' + +@pytest.mark.fails_on_dbstore +def test_bucket_recreate_new_acl(): + bucket_name = get_new_bucket_name() + client = get_client() + + client.create_bucket(Bucket=bucket_name) + e = assert_raises(ClientError, client.create_bucket, Bucket=bucket_name, ACL='public-read') + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'BucketAlreadyExists' + +def check_access_denied(fn, *args, **kwargs): + e = assert_raises(ClientError, fn, *args, **kwargs) + status = _get_status(e.response) + assert status == 403 + +def check_request_timeout(fn, *args, **kwargs): + e = assert_raises(ClientError, fn, *args, **kwargs) + status = _get_status(e.response) + assert status == 400 + +def check_grants(got, want): + """ + Check that grants list in got matches the dictionaries in want, + in any order. + """ + assert len(got) == len(want) + + # There are instances when got does not match due the order of item. + if got[0]["Grantee"].get("DisplayName"): + got.sort(key=lambda x: x["Grantee"].get("DisplayName")) + want.sort(key=lambda x: x["DisplayName"]) + + for g, w in zip(got, want): + w = dict(w) + g = dict(g) + assert g.pop('Permission', None) == w['Permission'] + assert g['Grantee'].pop('DisplayName', None) == w['DisplayName'] + assert g['Grantee'].pop('ID', None) == w['ID'] + assert g['Grantee'].pop('Type', None) == w['Type'] + assert g['Grantee'].pop('URI', None) == w['URI'] + assert g['Grantee'].pop('EmailAddress', None) == w['EmailAddress'] + assert g == {'Grantee': {}} + + +def test_bucket_acl_default(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.get_bucket_acl(Bucket=bucket_name) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + assert response['Owner']['DisplayName'] == display_name + assert response['Owner']['ID'] == user_id + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +@pytest.mark.fails_on_aws +def test_bucket_acl_canned_during_create(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read', Bucket=bucket_name) + response = client.get_bucket_acl(Bucket=bucket_name) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_bucket_acl_canned(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read', Bucket=bucket_name) + response = client.get_bucket_acl(Bucket=bucket_name) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + client.put_bucket_acl(ACL='private', Bucket=bucket_name) + response = client.get_bucket_acl(Bucket=bucket_name) + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_bucket_acl_canned_publicreadwrite(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + response = client.get_bucket_acl(Bucket=bucket_name) + + display_name = get_main_display_name() + user_id = get_main_user_id() + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='WRITE', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_bucket_acl_canned_authenticatedread(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(ACL='authenticated-read', Bucket=bucket_name) + response = client.get_bucket_acl(Bucket=bucket_name) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AuthenticatedUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_put_bucket_acl_grant_group_read(): + bucket_name = get_new_bucket() + client = get_client() + display_name = get_main_display_name() + user_id = get_main_user_id() + + grant = {'Grantee': {'Type': 'Group', 'URI': 'http://acs.amazonaws.com/groups/global/AllUsers'}, 'Permission': 'READ'} + policy = add_bucket_user_grant(bucket_name, grant) + + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy=policy) + + response = client.get_bucket_acl(Bucket=bucket_name) + + check_grants( + response['Grants'], + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_default(): + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + display_name = get_main_display_name() + user_id = get_main_user_id() + + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned_during_create(): + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(ACL='public-read', Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + display_name = get_main_display_name() + user_id = get_main_user_id() + + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned(): + bucket_name = get_new_bucket() + client = get_client() + + # Since it defaults to private, set it public-read first + client.put_object(ACL='public-read', Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + # Then back to private. + client.put_object_acl(ACL='private',Bucket=bucket_name, Key='foo') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + grants = response['Grants'] + + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned_publicreadwrite(): + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(ACL='public-read-write', Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='WRITE', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned_authenticatedread(): + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(ACL='authenticated-read', Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + display_name = get_main_display_name() + user_id = get_main_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AuthenticatedUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned_bucketownerread(): + bucket_name = get_new_bucket_name() + main_client = get_client() + alt_client = get_alt_client() + + main_client.create_bucket(Bucket=bucket_name, ACL='public-read-write') + + alt_client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + bucket_acl_response = main_client.get_bucket_acl(Bucket=bucket_name) + bucket_owner_id = bucket_acl_response['Grants'][2]['Grantee']['ID'] + bucket_owner_display_name = bucket_acl_response['Grants'][2]['Grantee']['DisplayName'] + + alt_client.put_object(ACL='bucket-owner-read', Bucket=bucket_name, Key='foo') + response = alt_client.get_object_acl(Bucket=bucket_name, Key='foo') + + alt_display_name = get_alt_display_name() + alt_user_id = get_alt_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='READ', + ID=bucket_owner_id, + DisplayName=bucket_owner_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def test_object_acl_canned_bucketownerfullcontrol(): + bucket_name = get_new_bucket_name() + main_client = get_client() + alt_client = get_alt_client() + + main_client.create_bucket(Bucket=bucket_name, ACL='public-read-write') + + alt_client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + bucket_acl_response = main_client.get_bucket_acl(Bucket=bucket_name) + bucket_owner_id = bucket_acl_response['Grants'][2]['Grantee']['ID'] + bucket_owner_display_name = bucket_acl_response['Grants'][2]['Grantee']['DisplayName'] + + alt_client.put_object(ACL='bucket-owner-full-control', Bucket=bucket_name, Key='foo') + response = alt_client.get_object_acl(Bucket=bucket_name, Key='foo') + + alt_display_name = get_alt_display_name() + alt_user_id = get_alt_user_id() + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='FULL_CONTROL', + ID=bucket_owner_id, + DisplayName=bucket_owner_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +@pytest.mark.fails_on_aws +def test_object_acl_full_control_verify_owner(): + bucket_name = get_new_bucket_name() + main_client = get_client() + alt_client = get_alt_client() + + main_client.create_bucket(Bucket=bucket_name, ACL='public-read-write') + + main_client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + grant = { 'Grants': [{'Grantee': {'ID': alt_user_id, 'Type': 'CanonicalUser' }, 'Permission': 'FULL_CONTROL'}], 'Owner': {'DisplayName': main_display_name, 'ID': main_user_id}} + + main_client.put_object_acl(Bucket=bucket_name, Key='foo', AccessControlPolicy=grant) + + grant = { 'Grants': [{'Grantee': {'ID': alt_user_id, 'Type': 'CanonicalUser' }, 'Permission': 'READ_ACP'}], 'Owner': {'DisplayName': main_display_name, 'ID': main_user_id}} + + alt_client.put_object_acl(Bucket=bucket_name, Key='foo', AccessControlPolicy=grant) + + response = alt_client.get_object_acl(Bucket=bucket_name, Key='foo') + assert response['Owner']['ID'] == main_user_id + +def add_obj_user_grant(bucket_name, key, grant): + """ + Adds a grant to the existing grants meant to be passed into + the AccessControlPolicy argument of put_object_acls for an object + owned by the main user, not the alt user + A grant is a dictionary in the form of: + {u'Grantee': {u'Type': 'type', u'DisplayName': 'name', u'ID': 'id'}, u'Permission': 'PERM'} + + """ + client = get_client() + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + response = client.get_object_acl(Bucket=bucket_name, Key=key) + + grants = response['Grants'] + grants.append(grant) + + grant = {'Grants': grants, 'Owner': {'DisplayName': main_display_name, 'ID': main_user_id}} + + return grant + +def test_object_acl_full_control_verify_attributes(): + bucket_name = get_new_bucket_name() + main_client = get_client() + alt_client = get_alt_client() + + main_client.create_bucket(Bucket=bucket_name, ACL='public-read-write') + + header = {'x-amz-foo': 'bar'} + # lambda to add any header + add_header = (lambda **kwargs: kwargs['params']['headers'].update(header)) + + main_client.meta.events.register('before-call.s3.PutObject', add_header) + main_client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = main_client.get_object(Bucket=bucket_name, Key='foo') + content_type = response['ContentType'] + etag = response['ETag'] + + alt_user_id = get_alt_user_id() + + grant = {'Grantee': {'ID': alt_user_id, 'Type': 'CanonicalUser' }, 'Permission': 'FULL_CONTROL'} + + grants = add_obj_user_grant(bucket_name, 'foo', grant) + + main_client.put_object_acl(Bucket=bucket_name, Key='foo', AccessControlPolicy=grants) + + response = main_client.get_object(Bucket=bucket_name, Key='foo') + assert content_type == response['ContentType'] + assert etag == response['ETag'] + +def test_bucket_acl_canned_private_to_private(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.put_bucket_acl(Bucket=bucket_name, ACL='private') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def add_bucket_user_grant(bucket_name, grant): + """ + Adds a grant to the existing grants meant to be passed into + the AccessControlPolicy argument of put_object_acls for an object + owned by the main user, not the alt user + A grant is a dictionary in the form of: + {u'Grantee': {u'Type': 'type', u'DisplayName': 'name', u'ID': 'id'}, u'Permission': 'PERM'} + """ + client = get_client() + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + response = client.get_bucket_acl(Bucket=bucket_name) + + grants = response['Grants'] + grants.append(grant) + + grant = {'Grants': grants, 'Owner': {'DisplayName': main_display_name, 'ID': main_user_id}} + + return grant + +def _check_object_acl(permission): + """ + Sets the permission on an object then checks to see + if it was set + """ + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + + policy = {} + policy['Owner'] = response['Owner'] + policy['Grants'] = response['Grants'] + policy['Grants'][0]['Permission'] = permission + + client.put_object_acl(Bucket=bucket_name, Key='foo', AccessControlPolicy=policy) + + response = client.get_object_acl(Bucket=bucket_name, Key='foo') + grants = response['Grants'] + + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + check_grants( + grants, + [ + dict( + Permission=permission, + ID=main_user_id, + DisplayName=main_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + +@pytest.mark.fails_on_aws +def test_object_acl(): + _check_object_acl('FULL_CONTROL') + +@pytest.mark.fails_on_aws +def test_object_acl_write(): + _check_object_acl('WRITE') + +@pytest.mark.fails_on_aws +def test_object_acl_writeacp(): + _check_object_acl('WRITE_ACP') + + +@pytest.mark.fails_on_aws +def test_object_acl_read(): + _check_object_acl('READ') + + +@pytest.mark.fails_on_aws +def test_object_acl_readacp(): + _check_object_acl('READ_ACP') + + +def _bucket_acl_grant_userid(permission): + """ + create a new bucket, grant a specific user the specified + permission, read back the acl and verify correct setting + """ + bucket_name = get_new_bucket() + client = get_client() + + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + + grant = {'Grantee': {'ID': alt_user_id, 'Type': 'CanonicalUser' }, 'Permission': permission} + + grant = add_bucket_user_grant(bucket_name, grant) + + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy=grant) + + response = client.get_bucket_acl(Bucket=bucket_name) + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission=permission, + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='FULL_CONTROL', + ID=main_user_id, + DisplayName=main_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + return bucket_name + +def _check_bucket_acl_grant_can_read(bucket_name): + """ + verify ability to read the specified bucket + """ + alt_client = get_alt_client() + response = alt_client.head_bucket(Bucket=bucket_name) + +def _check_bucket_acl_grant_cant_read(bucket_name): + """ + verify inability to read the specified bucket + """ + alt_client = get_alt_client() + check_access_denied(alt_client.head_bucket, Bucket=bucket_name) + +def _check_bucket_acl_grant_can_readacp(bucket_name): + """ + verify ability to read acls on specified bucket + """ + alt_client = get_alt_client() + alt_client.get_bucket_acl(Bucket=bucket_name) + +def _check_bucket_acl_grant_cant_readacp(bucket_name): + """ + verify inability to read acls on specified bucket + """ + alt_client = get_alt_client() + check_access_denied(alt_client.get_bucket_acl, Bucket=bucket_name) + +def _check_bucket_acl_grant_can_write(bucket_name): + """ + verify ability to write the specified bucket + """ + alt_client = get_alt_client() + alt_client.put_object(Bucket=bucket_name, Key='foo-write', Body='bar') + +def _check_bucket_acl_grant_cant_write(bucket_name): + + """ + verify inability to write the specified bucket + """ + alt_client = get_alt_client() + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key='foo-write', Body='bar') + +def _check_bucket_acl_grant_can_writeacp(bucket_name): + """ + verify ability to set acls on the specified bucket + """ + alt_client = get_alt_client() + alt_client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + +def _check_bucket_acl_grant_cant_writeacp(bucket_name): + """ + verify inability to set acls on the specified bucket + """ + alt_client = get_alt_client() + check_access_denied(alt_client.put_bucket_acl,Bucket=bucket_name, ACL='public-read') + +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_userid_fullcontrol(): + bucket_name = _bucket_acl_grant_userid('FULL_CONTROL') + + # alt user can read + _check_bucket_acl_grant_can_read(bucket_name) + # can read acl + _check_bucket_acl_grant_can_readacp(bucket_name) + # can write + _check_bucket_acl_grant_can_write(bucket_name) + # can write acl + _check_bucket_acl_grant_can_writeacp(bucket_name) + + client = get_client() + + bucket_acl_response = client.get_bucket_acl(Bucket=bucket_name) + owner_id = bucket_acl_response['Owner']['ID'] + owner_display_name = bucket_acl_response['Owner']['DisplayName'] + + main_display_name = get_main_display_name() + main_user_id = get_main_user_id() + + assert owner_id == main_user_id + assert owner_display_name == main_display_name + +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_userid_read(): + bucket_name = _bucket_acl_grant_userid('READ') + + # alt user can read + _check_bucket_acl_grant_can_read(bucket_name) + # can't read acl + _check_bucket_acl_grant_cant_readacp(bucket_name) + # can't write + _check_bucket_acl_grant_cant_write(bucket_name) + # can't write acl + _check_bucket_acl_grant_cant_writeacp(bucket_name) + +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_userid_readacp(): + bucket_name = _bucket_acl_grant_userid('READ_ACP') + + # alt user can't read + _check_bucket_acl_grant_cant_read(bucket_name) + # can read acl + _check_bucket_acl_grant_can_readacp(bucket_name) + # can't write + _check_bucket_acl_grant_cant_write(bucket_name) + # can't write acp + #_check_bucket_acl_grant_cant_writeacp_can_readacp(bucket) + _check_bucket_acl_grant_cant_writeacp(bucket_name) + +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_userid_write(): + bucket_name = _bucket_acl_grant_userid('WRITE') + + # alt user can't read + _check_bucket_acl_grant_cant_read(bucket_name) + # can't read acl + _check_bucket_acl_grant_cant_readacp(bucket_name) + # can write + _check_bucket_acl_grant_can_write(bucket_name) + # can't write acl + _check_bucket_acl_grant_cant_writeacp(bucket_name) + +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_userid_writeacp(): + bucket_name = _bucket_acl_grant_userid('WRITE_ACP') + + # alt user can't read + _check_bucket_acl_grant_cant_read(bucket_name) + # can't read acl + _check_bucket_acl_grant_cant_readacp(bucket_name) + # can't write + _check_bucket_acl_grant_cant_write(bucket_name) + # can write acl + _check_bucket_acl_grant_can_writeacp(bucket_name) + +def test_bucket_acl_grant_nonexist_user(): + bucket_name = get_new_bucket() + client = get_client() + + bad_user_id = '_foo' + + #response = client.get_bucket_acl(Bucket=bucket_name) + grant = {'Grantee': {'ID': bad_user_id, 'Type': 'CanonicalUser' }, 'Permission': 'FULL_CONTROL'} + + grant = add_bucket_user_grant(bucket_name, grant) + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name, AccessControlPolicy=grant) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +def _get_acl_header(user_id=None, perms=None): + all_headers = ["read", "write", "read-acp", "write-acp", "full-control"] + headers = [] + + if user_id == None: + user_id = get_alt_user_id() + + if perms != None: + for perm in perms: + header = ("x-amz-grant-{perm}".format(perm=perm), "id={uid}".format(uid=user_id)) + headers.append(header) + + else: + for perm in all_headers: + header = ("x-amz-grant-{perm}".format(perm=perm), "id={uid}".format(uid=user_id)) + headers.append(header) + + return headers + +@pytest.mark.fails_on_dho +@pytest.mark.fails_on_aws +def test_object_header_acl_grants(): + bucket_name = get_new_bucket() + client = get_client() + + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + + headers = _get_acl_header() + + def add_headers_before_sign(**kwargs): + updated_headers = (kwargs['request'].__dict__['headers'].__dict__['_headers'] + headers) + kwargs['request'].__dict__['headers'].__dict__['_headers'] = updated_headers + + client.meta.events.register('before-sign.s3.PutObject', add_headers_before_sign) + + client.put_object(Bucket=bucket_name, Key='foo_key', Body='bar') + + response = client.get_object_acl(Bucket=bucket_name, Key='foo_key') + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='WRITE', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='READ_ACP', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='WRITE_ACP', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='FULL_CONTROL', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +@pytest.mark.fails_on_dho +@pytest.mark.fails_on_aws +def test_bucket_header_acl_grants(): + headers = _get_acl_header() + bucket_name = get_new_bucket_name() + client = get_client() + + headers = _get_acl_header() + + def add_headers_before_sign(**kwargs): + updated_headers = (kwargs['request'].__dict__['headers'].__dict__['_headers'] + headers) + kwargs['request'].__dict__['headers'].__dict__['_headers'] = updated_headers + + client.meta.events.register('before-sign.s3.CreateBucket', add_headers_before_sign) + + client.create_bucket(Bucket=bucket_name) + + response = client.get_bucket_acl(Bucket=bucket_name) + + grants = response['Grants'] + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + + check_grants( + grants, + [ + dict( + Permission='READ', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='WRITE', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='READ_ACP', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='WRITE_ACP', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='FULL_CONTROL', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + alt_client = get_alt_client() + + alt_client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + # set bucket acl to public-read-write so that teardown can work + alt_client.put_bucket_acl(Bucket=bucket_name, ACL='public-read-write') + + +# This test will fail on DH Objects. DHO allows multiple users with one account, which +# would violate the uniqueness requirement of a user's email. As such, DHO users are +# created without an email. +@pytest.mark.fails_on_aws +def test_bucket_acl_grant_email(): + bucket_name = get_new_bucket() + client = get_client() + + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + alt_email_address = get_alt_email() + + main_user_id = get_main_user_id() + main_display_name = get_main_display_name() + + grant = {'Grantee': {'EmailAddress': alt_email_address, 'Type': 'AmazonCustomerByEmail' }, 'Permission': 'FULL_CONTROL'} + + grant = add_bucket_user_grant(bucket_name, grant) + + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy = grant) + + response = client.get_bucket_acl(Bucket=bucket_name) + + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='FULL_CONTROL', + ID=alt_user_id, + DisplayName=alt_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + dict( + Permission='FULL_CONTROL', + ID=main_user_id, + DisplayName=main_display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ] + ) + +def test_bucket_acl_grant_email_not_exist(): + # behavior not documented by amazon + bucket_name = get_new_bucket() + client = get_client() + + alt_user_id = get_alt_user_id() + alt_display_name = get_alt_display_name() + alt_email_address = get_alt_email() + + NONEXISTENT_EMAIL = 'doesnotexist@dreamhost.com.invalid' + grant = {'Grantee': {'EmailAddress': NONEXISTENT_EMAIL, 'Type': 'AmazonCustomerByEmail'}, 'Permission': 'FULL_CONTROL'} + + grant = add_bucket_user_grant(bucket_name, grant) + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name, AccessControlPolicy = grant) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'UnresolvableGrantByEmailAddress' + +def test_bucket_acl_revoke_all(): + # revoke all access, including the owner's access + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + response = client.get_bucket_acl(Bucket=bucket_name) + old_grants = response['Grants'] + policy = {} + policy['Owner'] = response['Owner'] + # clear grants + policy['Grants'] = [] + + # remove read/write permission for everyone + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy=policy) + + response = client.get_bucket_acl(Bucket=bucket_name) + + assert len(response['Grants']) == 0 + + # set policy back to original so that bucket can be cleaned up + policy['Grants'] = old_grants + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy=policy) + +def _setup_access(bucket_acl, object_acl): + """ + Simple test fixture: create a bucket with given ACL, with objects: + - a: owning user, given ACL + - a2: same object accessed by some other user + - b: owning user, default ACL in bucket w/given ACL + - b2: same object accessed by a some other user + """ + bucket_name = get_new_bucket() + client = get_client() + + key1 = 'foo' + key2 = 'bar' + newkey = 'new' + + client.put_bucket_acl(Bucket=bucket_name, ACL=bucket_acl) + client.put_object(Bucket=bucket_name, Key=key1, Body='foocontent') + client.put_object_acl(Bucket=bucket_name, Key=key1, ACL=object_acl) + client.put_object(Bucket=bucket_name, Key=key2, Body='barcontent') + + return bucket_name, key1, key2, newkey + +def get_bucket_key_names(bucket_name): + objs_list = get_objects_list(bucket_name) + return frozenset(obj for obj in objs_list) + +def list_bucket_storage_class(client, bucket_name): + result = defaultdict(list) + response = client.list_object_versions(Bucket=bucket_name) + for k in response['Versions']: + result[k['StorageClass']].append(k) + + return result + +def list_bucket_versions(client, bucket_name): + result = defaultdict(list) + response = client.list_object_versions(Bucket=bucket_name) + for k in response['Versions']: + result[response['Name']].append(k) + + return result + +def test_access_bucket_private_object_private(): + # all the test_access_* tests follow this template + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='private') + + alt_client = get_alt_client() + # acled object read fail + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key1) + # default object read fail + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key2) + # bucket read fail + check_access_denied(alt_client.list_objects, Bucket=bucket_name) + + # acled object write fail + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='barcontent') + # NOTE: The above put's causes the connection to go bad, therefore the client can't be used + # anymore. This can be solved either by: + # 1) putting an empty string ('') in the 'Body' field of those put_object calls + # 2) getting a new client hence the creation of alt_client{2,3} for the tests below + # TODO: Test it from another host and on AWS, Report this to Amazon, if findings are identical + + alt_client2 = get_alt_client() + # default object write fail + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + # bucket write fail + alt_client3 = get_alt_client() + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +@pytest.mark.list_objects_v2 +def test_access_bucket_private_objectv2_private(): + # all the test_access_* tests follow this template + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='private') + + alt_client = get_alt_client() + # acled object read fail + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key1) + # default object read fail + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key2) + # bucket read fail + check_access_denied(alt_client.list_objects_v2, Bucket=bucket_name) + + # acled object write fail + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='barcontent') + # NOTE: The above put's causes the connection to go bad, therefore the client can't be used + # anymore. This can be solved either by: + # 1) putting an empty string ('') in the 'Body' field of those put_object calls + # 2) getting a new client hence the creation of alt_client{2,3} for the tests below + # TODO: Test it from another host and on AWS, Report this to Amazon, if findings are identical + + alt_client2 = get_alt_client() + # default object write fail + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + # bucket write fail + alt_client3 = get_alt_client() + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_private_object_publicread(): + + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='public-read') + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + + # a should be public-read, b gets default (private) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + check_access_denied(alt_client3.list_objects, Bucket=bucket_name) + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +@pytest.mark.list_objects_v2 +def test_access_bucket_private_objectv2_publicread(): + + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='public-read') + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + + # a should be public-read, b gets default (private) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + check_access_denied(alt_client3.list_objects_v2, Bucket=bucket_name) + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_private_object_publicreadwrite(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='public-read-write') + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + + # a should be public-read-only ... because it is in a private bucket + # b gets default (private) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + check_access_denied(alt_client3.list_objects, Bucket=bucket_name) + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +@pytest.mark.list_objects_v2 +def test_access_bucket_private_objectv2_publicreadwrite(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='private', object_acl='public-read-write') + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + + # a should be public-read-only ... because it is in a private bucket + # b gets default (private) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + check_access_denied(alt_client3.list_objects_v2, Bucket=bucket_name) + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_publicread_object_private(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read', object_acl='private') + alt_client = get_alt_client() + + # a should be private, b gets default (private) + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key1) + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='barcontent') + + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + + objs = get_objects_list(bucket=bucket_name, client=alt_client3) + + assert objs == ['bar', 'foo'] + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_publicread_object_publicread(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read', object_acl='public-read') + alt_client = get_alt_client() + + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + # a should be public-read, b gets default (private) + body = _get_body(response) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + + objs = get_objects_list(bucket=bucket_name, client=alt_client3) + + assert objs == ['bar', 'foo'] + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + + +def test_access_bucket_publicread_object_publicreadwrite(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read', object_acl='public-read-write') + alt_client = get_alt_client() + + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + + # a should be public-read-only ... because it is in a r/o bucket + # b gets default (private) + assert body == 'foocontent' + + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1, Body='foooverwrite') + + alt_client2 = get_alt_client() + check_access_denied(alt_client2.get_object, Bucket=bucket_name, Key=key2) + check_access_denied(alt_client2.put_object, Bucket=bucket_name, Key=key2, Body='baroverwrite') + + alt_client3 = get_alt_client() + + objs = get_objects_list(bucket=bucket_name, client=alt_client3) + + assert objs == ['bar', 'foo'] + check_access_denied(alt_client3.put_object, Bucket=bucket_name, Key=newkey, Body='newcontent') + + +def test_access_bucket_publicreadwrite_object_private(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read-write', object_acl='private') + alt_client = get_alt_client() + + # a should be private, b gets default (private) + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key1) + alt_client.put_object(Bucket=bucket_name, Key=key1, Body='barcontent') + + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key2) + alt_client.put_object(Bucket=bucket_name, Key=key2, Body='baroverwrite') + + objs = get_objects_list(bucket=bucket_name, client=alt_client) + assert objs == ['bar', 'foo'] + alt_client.put_object(Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_publicreadwrite_object_publicread(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read-write', object_acl='public-read') + alt_client = get_alt_client() + + # a should be public-read, b gets default (private) + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + + body = _get_body(response) + assert body == 'foocontent' + alt_client.put_object(Bucket=bucket_name, Key=key1, Body='barcontent') + + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key2) + alt_client.put_object(Bucket=bucket_name, Key=key2, Body='baroverwrite') + + objs = get_objects_list(bucket=bucket_name, client=alt_client) + assert objs == ['bar', 'foo'] + alt_client.put_object(Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_access_bucket_publicreadwrite_object_publicreadwrite(): + bucket_name, key1, key2, newkey = _setup_access(bucket_acl='public-read-write', object_acl='public-read-write') + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key=key1) + body = _get_body(response) + + # a should be public-read-write, b gets default (private) + assert body == 'foocontent' + alt_client.put_object(Bucket=bucket_name, Key=key1, Body='foooverwrite') + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key=key2) + alt_client.put_object(Bucket=bucket_name, Key=key2, Body='baroverwrite') + objs = get_objects_list(bucket=bucket_name, client=alt_client) + assert objs == ['bar', 'foo'] + alt_client.put_object(Bucket=bucket_name, Key=newkey, Body='newcontent') + +def test_buckets_create_then_list(): + client = get_client() + bucket_names = [] + for i in range(5): + bucket_name = get_new_bucket_name() + bucket_names.append(bucket_name) + + for name in bucket_names: + client.create_bucket(Bucket=name) + + response = client.list_buckets() + bucket_dicts = response['Buckets'] + buckets_list = [] + + buckets_list = get_buckets_list() + + for name in bucket_names: + if name not in buckets_list: + raise RuntimeError("S3 implementation's GET on Service did not return bucket we created: %r", bucket.name) + +def test_buckets_list_ctime(): + # check that creation times are within a day + before = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1) + + client = get_client() + buckets = [] + for i in range(5): + name = get_new_bucket_name() + client.create_bucket(Bucket=name) + buckets.append(name) + + response = client.list_buckets() + for bucket in response['Buckets']: + if bucket['Name'] in buckets: + ctime = bucket['CreationDate'] + assert before <= ctime, '%r > %r' % (before, ctime) + +@pytest.mark.fails_on_aws +def test_list_buckets_anonymous(): + # Get a connection with bad authorization, then change it to be our new Anonymous auth mechanism, + # emulating standard HTTP access. + # + # While it may have been possible to use httplib directly, doing it this way takes care of also + # allowing us to vary the calling format in testing. + unauthenticated_client = get_unauthenticated_client() + response = unauthenticated_client.list_buckets() + assert len(response['Buckets']) == 0 + +def test_list_buckets_invalid_auth(): + bad_auth_client = get_bad_auth_client() + e = assert_raises(ClientError, bad_auth_client.list_buckets) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +def test_list_buckets_bad_auth(): + main_access_key = get_main_aws_access_key() + bad_auth_client = get_bad_auth_client(aws_access_key_id=main_access_key) + e = assert_raises(ClientError, bad_auth_client.list_buckets) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +@pytest.mark.fails_on_dbstore +def test_list_buckets_paginated(): + client = get_client() + + response = client.list_buckets(MaxBuckets=1) + assert len(response['Buckets']) == 0 + assert 'ContinuationToken' not in response + + bucket1 = get_new_bucket() + + response = client.list_buckets(MaxBuckets=1) + assert len(response['Buckets']) == 1 + assert response['Buckets'][0]['Name'] == bucket1 + assert 'ContinuationToken' not in response + + bucket2 = get_new_bucket() + + response = client.list_buckets(MaxBuckets=1) + assert len(response['Buckets']) == 1 + assert response['Buckets'][0]['Name'] == bucket1 + continuation = response['ContinuationToken'] + + response = client.list_buckets(MaxBuckets=1, ContinuationToken=continuation) + assert len(response['Buckets']) == 1 + assert response['Buckets'][0]['Name'] == bucket2 + assert 'ContinuationToken' not in response + +@pytest.fixture +def override_prefix_a(): + nuke_prefixed_buckets(prefix='a'+get_prefix()) + yield + nuke_prefixed_buckets(prefix='a'+get_prefix()) + +# this test goes outside the user-configure prefix because it needs to +# control the initial character of the bucket name +def test_bucket_create_naming_good_starts_alpha(override_prefix_a): + check_good_bucket_name('foo', _prefix='a'+get_prefix()) + +@pytest.fixture +def override_prefix_0(): + nuke_prefixed_buckets(prefix='0'+get_prefix()) + yield + nuke_prefixed_buckets(prefix='0'+get_prefix()) + +# this test goes outside the user-configure prefix because it needs to +# control the initial character of the bucket name +def test_bucket_create_naming_good_starts_digit(override_prefix_0): + check_good_bucket_name('foo', _prefix='0'+get_prefix()) + +def test_bucket_create_naming_good_contains_period(): + check_good_bucket_name('aaa.111') + +def test_bucket_create_naming_good_contains_hyphen(): + check_good_bucket_name('aaa-111') + +def test_bucket_recreate_not_overriding(): + key_names = ['mykey1', 'mykey2'] + bucket_name = _create_objects(keys=key_names) + + objs_list = get_objects_list(bucket_name) + assert key_names == objs_list + + client = get_client() + client.create_bucket(Bucket=bucket_name) + + objs_list = get_objects_list(bucket_name) + assert key_names == objs_list + +@pytest.mark.fails_on_dbstore +def test_bucket_create_special_key_names(): + key_names = [ + ' ', + '"', + '$', + '%', + '&', + '\'', + '<', + '>', + '_', + '_ ', + '_ _', + '__', + ] + + bucket_name = _create_objects(keys=key_names) + + objs_list = get_objects_list(bucket_name) + assert key_names == objs_list + + client = get_client() + + for name in key_names: + assert name in objs_list + response = client.get_object(Bucket=bucket_name, Key=name) + body = _get_body(response) + assert name == body + client.put_object_acl(Bucket=bucket_name, Key=name, ACL='private') + +def test_bucket_list_special_prefix(): + key_names = ['_bla/1', '_bla/2', '_bla/3', '_bla/4', 'abcd'] + bucket_name = _create_objects(keys=key_names) + + objs_list = get_objects_list(bucket_name) + + assert len(objs_list) == 5 + + objs_list = get_objects_list(bucket_name, prefix='_bla/') + assert len(objs_list) == 4 + +@pytest.mark.fails_on_dbstore +def test_object_copy_zero_size(): + key = 'foo123bar' + bucket_name = _create_objects(keys=[key]) + fp_a = FakeWriteFile(0, '') + client = get_client() + client.put_object(Bucket=bucket_name, Key=key, Body=fp_a) + + copy_source = {'Bucket': bucket_name, 'Key': key} + + client.copy(copy_source, bucket_name, 'bar321foo') + response = client.get_object(Bucket=bucket_name, Key='bar321foo') + assert response['ContentLength'] == 0 + +@pytest.mark.fails_on_dbstore +def test_object_copy_16m(): + bucket_name = get_new_bucket() + key1 = 'obj1' + client = get_client() + client.put_object(Bucket=bucket_name, Key=key1, Body=bytearray(16*1024*1024)) + + copy_source = {'Bucket': bucket_name, 'Key': key1} + key2 = 'obj2' + client.copy_object(Bucket=bucket_name, Key=key2, CopySource=copy_source) + response = client.get_object(Bucket=bucket_name, Key=key2) + assert response['ContentLength'] == 16*1024*1024 + +@pytest.mark.fails_on_dbstore +def test_object_copy_same_bucket(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + + client.copy(copy_source, bucket_name, 'bar321foo') + + response = client.get_object(Bucket=bucket_name, Key='bar321foo') + body = _get_body(response) + assert 'foo' == body + +@pytest.mark.fails_on_dbstore +def test_object_copy_verify_contenttype(): + bucket_name = get_new_bucket() + client = get_client() + + content_type = 'text/bla' + client.put_object(Bucket=bucket_name, ContentType=content_type, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + + client.copy(copy_source, bucket_name, 'bar321foo') + + response = client.get_object(Bucket=bucket_name, Key='bar321foo') + body = _get_body(response) + assert 'foo' == body + response_content_type = response['ContentType'] + assert response_content_type == content_type + +def test_object_copy_to_itself(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + + e = assert_raises(ClientError, client.copy, copy_source, bucket_name, 'foo123bar') + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRequest' + +@pytest.mark.fails_on_dbstore +def test_object_copy_to_itself_with_metadata(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo123bar', Body='foo') + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + metadata = {'foo': 'bar'} + + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key='foo123bar', Metadata=metadata, MetadataDirective='REPLACE') + response = client.get_object(Bucket=bucket_name, Key='foo123bar') + assert response['Metadata'] == metadata + +@pytest.mark.fails_on_dbstore +def test_object_copy_diff_bucket(): + bucket_name1 = get_new_bucket() + bucket_name2 = get_new_bucket() + + client = get_client() + client.put_object(Bucket=bucket_name1, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name1, 'Key': 'foo123bar'} + + client.copy(copy_source, bucket_name2, 'bar321foo') + + response = client.get_object(Bucket=bucket_name2, Key='bar321foo') + body = _get_body(response) + assert 'foo' == body + +def test_object_copy_not_owned_bucket(): + client = get_client() + alt_client = get_alt_client() + bucket_name1 = get_new_bucket_name() + bucket_name2 = get_new_bucket_name() + client.create_bucket(Bucket=bucket_name1) + alt_client.create_bucket(Bucket=bucket_name2) + + client.put_object(Bucket=bucket_name1, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name1, 'Key': 'foo123bar'} + + e = assert_raises(ClientError, alt_client.copy, copy_source, bucket_name2, 'bar321foo') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +def test_object_copy_not_owned_object_bucket(): + client = get_client() + alt_client = get_alt_client() + bucket_name = get_new_bucket_name() + client.create_bucket(Bucket=bucket_name) + client.put_object(Bucket=bucket_name, Key='foo123bar', Body='foo') + + alt_user_id = get_alt_user_id() + + grant = {'Grantee': {'ID': alt_user_id, 'Type': 'CanonicalUser' }, 'Permission': 'FULL_CONTROL'} + grants = add_obj_user_grant(bucket_name, 'foo123bar', grant) + client.put_object_acl(Bucket=bucket_name, Key='foo123bar', AccessControlPolicy=grants) + + grant = add_bucket_user_grant(bucket_name, grant) + client.put_bucket_acl(Bucket=bucket_name, AccessControlPolicy=grant) + + alt_client.get_object(Bucket=bucket_name, Key='foo123bar') + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + alt_client.copy(copy_source, bucket_name, 'bar321foo') + +@pytest.mark.fails_on_dbstore +def test_object_copy_canned_acl(): + bucket_name = get_new_bucket() + client = get_client() + alt_client = get_alt_client() + client.put_object(Bucket=bucket_name, Key='foo123bar', Body='foo') + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key='bar321foo', ACL='public-read') + # check ACL is applied by doing GET from another user + alt_client.get_object(Bucket=bucket_name, Key='bar321foo') + + + metadata={'abc': 'def'} + copy_source = {'Bucket': bucket_name, 'Key': 'bar321foo'} + client.copy_object(ACL='public-read', Bucket=bucket_name, CopySource=copy_source, Key='foo123bar', Metadata=metadata, MetadataDirective='REPLACE') + + # check ACL is applied by doing GET from another user + alt_client.get_object(Bucket=bucket_name, Key='foo123bar') + +@pytest.mark.fails_on_dbstore +def test_object_copy_retaining_metadata(): + for size in [3, 1024 * 1024]: + bucket_name = get_new_bucket() + client = get_client() + content_type = 'audio/ogg' + + metadata = {'key1': 'value1', 'key2': 'value2'} + client.put_object(Bucket=bucket_name, Key='foo123bar', Metadata=metadata, ContentType=content_type, Body=bytearray(size)) + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key='bar321foo') + + response = client.get_object(Bucket=bucket_name, Key='bar321foo') + assert content_type == response['ContentType'] + assert metadata == response['Metadata'] + body = _get_body(response) + assert size == response['ContentLength'] + +@pytest.mark.fails_on_dbstore +def test_object_copy_replacing_metadata(): + for size in [3, 1024 * 1024]: + bucket_name = get_new_bucket() + client = get_client() + content_type = 'audio/ogg' + + metadata = {'key1': 'value1', 'key2': 'value2'} + client.put_object(Bucket=bucket_name, Key='foo123bar', Metadata=metadata, ContentType=content_type, Body=bytearray(size)) + + metadata = {'key3': 'value3', 'key2': 'value2'} + content_type = 'audio/mpeg' + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key='bar321foo', Metadata=metadata, MetadataDirective='REPLACE', ContentType=content_type) + + response = client.get_object(Bucket=bucket_name, Key='bar321foo') + assert content_type == response['ContentType'] + assert metadata == response['Metadata'] + assert size == response['ContentLength'] + +def test_object_copy_bucket_not_found(): + bucket_name = get_new_bucket() + client = get_client() + + copy_source = {'Bucket': bucket_name + "-fake", 'Key': 'foo123bar'} + e = assert_raises(ClientError, client.copy, copy_source, bucket_name, 'bar321foo') + status = _get_status(e.response) + assert status == 404 + +def test_object_copy_key_not_found(): + bucket_name = get_new_bucket() + client = get_client() + + copy_source = {'Bucket': bucket_name, 'Key': 'foo123bar'} + e = assert_raises(ClientError, client.copy, copy_source, bucket_name, 'bar321foo') + status = _get_status(e.response) + assert status == 404 + +@pytest.mark.fails_on_dbstore +def test_object_copy_versioned_bucket(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + size = 1*5 + data = bytearray(size) + data_str = data.decode() + key1 = 'foo123bar' + client.put_object(Bucket=bucket_name, Key=key1, Body=data) + + response = client.get_object(Bucket=bucket_name, Key=key1) + version_id = response['VersionId'] + + # copy object in the same bucket + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key2 = 'bar321foo' + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=key2) + response = client.get_object(Bucket=bucket_name, Key=key2) + body = _get_body(response) + assert data_str == body + assert size == response['ContentLength'] + + + # second copy + version_id2 = response['VersionId'] + copy_source = {'Bucket': bucket_name, 'Key': key2, 'VersionId': version_id2} + key3 = 'bar321foo2' + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=key3) + response = client.get_object(Bucket=bucket_name, Key=key3) + body = _get_body(response) + assert data_str == body + assert size == response['ContentLength'] + + # copy to another versioned bucket + bucket_name2 = get_new_bucket() + check_configure_versioning_retry(bucket_name2, "Enabled", "Enabled") + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key4 = 'bar321foo3' + client.copy_object(Bucket=bucket_name2, CopySource=copy_source, Key=key4) + response = client.get_object(Bucket=bucket_name2, Key=key4) + body = _get_body(response) + assert data_str == body + assert size == response['ContentLength'] + + # copy to another non versioned bucket + bucket_name3 = get_new_bucket() + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key5 = 'bar321foo4' + client.copy_object(Bucket=bucket_name3, CopySource=copy_source, Key=key5) + response = client.get_object(Bucket=bucket_name3, Key=key5) + body = _get_body(response) + assert data_str == body + assert size == response['ContentLength'] + + # copy from a non versioned bucket + copy_source = {'Bucket': bucket_name3, 'Key': key5} + key6 = 'foo123bar2' + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=key6) + response = client.get_object(Bucket=bucket_name, Key=key6) + body = _get_body(response) + assert data_str == body + assert size == response['ContentLength'] + +@pytest.mark.fails_on_dbstore +def test_object_copy_versioned_url_encoding(): + bucket = get_new_bucket_resource() + check_configure_versioning_retry(bucket.name, "Enabled", "Enabled") + src_key = 'foo?bar' + src = bucket.put_object(Key=src_key) + src.load() # HEAD request tests that the key exists + + # copy object in the same bucket + dst_key = 'bar&foo' + dst = bucket.Object(dst_key) + dst.copy_from(CopySource={'Bucket': src.bucket_name, 'Key': src.key, 'VersionId': src.version_id}) + dst.load() # HEAD request tests that the key exists + +def generate_random(size, part_size=5*1024*1024): + """ + Generate the specified number random data. + (actually each MB is a repetition of the first KB) + """ + chunk = 1024 + allowed = string.ascii_letters + for x in range(0, size, part_size): + strpart = ''.join([allowed[random.randint(0, len(allowed) - 1)] for _ in range(chunk)]) + s = '' + left = size - x + this_part_size = min(left, part_size) + for y in range(this_part_size // chunk): + s = s + strpart + if this_part_size > len(s): + s = s + strpart[0:this_part_size - len(s)] + yield s + if (x == size): + return + +def _multipart_upload(bucket_name, key, size, part_size=5*1024*1024, client=None, content_type=None, metadata=None, resend_parts=[], tagging=None): + """ + generate a multi-part upload for a random file of specifed size, + if requested, generate a list of the parts + return the upload descriptor + """ + if client == None: + client = get_client() + + + if content_type == None and metadata == None: + if tagging is not None: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, Tagging=tagging) + else: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + else: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, Metadata=metadata, ContentType=content_type) + + upload_id = response['UploadId'] + s = '' + parts = [] + for i, part in enumerate(generate_random(size, part_size)): + # part_num is necessary because PartNumber for upload_part and in parts must start at 1 and i starts at 0 + part_num = i+1 + s += part + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': part_num}) + if i in resend_parts: + client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part) + + return (upload_id, s, parts) + +def _multipart_upload_checksum(bucket_name, key, size, part_size=5*1024*1024, client=None, content_type=None, metadata=None, resend_parts=[]): + """ + generate a multi-part upload for a random file of specifed size, + if requested, generate a list of the parts + return the upload descriptor + """ + if client == None: + client = get_client() + + + if content_type == None and metadata == None: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, ChecksumAlgorithm='SHA256') + else: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, Metadata=metadata, ContentType=content_type, + ChecksumAlgorithm='SHA256') + + upload_id = response['UploadId'] + s = '' + parts = [] + part_checksums = [] + for i, part in enumerate(generate_random(size, part_size)): + # part_num is necessary because PartNumber for upload_part and in parts must start at 1 and i starts at 0 + part_num = i+1 + s += part + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part, + ChecksumAlgorithm='SHA256') + + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': part_num}) + + armored_part_cksum = base64.b64encode(hashlib.sha256(part.encode('utf-8')).digest()) + part_checksums.append(armored_part_cksum.decode()) + + if i in resend_parts: + client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part, + ChecksumAlgorithm='SHA256') + + return (upload_id, s, parts, part_checksums) + +@pytest.mark.fails_on_dbstore +def test_object_copy_versioning_multipart_upload(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key1 = "srcmultipart" + key1_metadata = {'foo': 'bar'} + content_type = 'text/bla' + objlen = 30 * 1024 * 1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key1, size=objlen, content_type=content_type, metadata=key1_metadata) + client.complete_multipart_upload(Bucket=bucket_name, Key=key1, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.get_object(Bucket=bucket_name, Key=key1) + key1_size = response['ContentLength'] + version_id = response['VersionId'] + + # copy object in the same bucket + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key2 = 'dstmultipart' + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=key2) + response = client.get_object(Bucket=bucket_name, Key=key2) + version_id2 = response['VersionId'] + body = _get_body(response) + assert data == body + assert key1_size == response['ContentLength'] + assert key1_metadata == response['Metadata'] + assert content_type == response['ContentType'] + + # second copy + copy_source = {'Bucket': bucket_name, 'Key': key2, 'VersionId': version_id2} + key3 = 'dstmultipart2' + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=key3) + response = client.get_object(Bucket=bucket_name, Key=key3) + body = _get_body(response) + assert data == body + assert key1_size == response['ContentLength'] + assert key1_metadata == response['Metadata'] + assert content_type == response['ContentType'] + + # copy to another versioned bucket + bucket_name2 = get_new_bucket() + check_configure_versioning_retry(bucket_name2, "Enabled", "Enabled") + + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key4 = 'dstmultipart3' + client.copy_object(Bucket=bucket_name2, CopySource=copy_source, Key=key4) + response = client.get_object(Bucket=bucket_name2, Key=key4) + body = _get_body(response) + assert data == body + assert key1_size == response['ContentLength'] + assert key1_metadata == response['Metadata'] + assert content_type == response['ContentType'] + + # copy to another non versioned bucket + bucket_name3 = get_new_bucket() + copy_source = {'Bucket': bucket_name, 'Key': key1, 'VersionId': version_id} + key5 = 'dstmultipart4' + client.copy_object(Bucket=bucket_name3, CopySource=copy_source, Key=key5) + response = client.get_object(Bucket=bucket_name3, Key=key5) + body = _get_body(response) + assert data == body + assert key1_size == response['ContentLength'] + assert key1_metadata == response['Metadata'] + assert content_type == response['ContentType'] + + # copy from a non versioned bucket + copy_source = {'Bucket': bucket_name3, 'Key': key5} + key6 = 'dstmultipart5' + client.copy_object(Bucket=bucket_name3, CopySource=copy_source, Key=key6) + response = client.get_object(Bucket=bucket_name3, Key=key6) + body = _get_body(response) + assert data == body + assert key1_size == response['ContentLength'] + assert key1_metadata == response['Metadata'] + assert content_type == response['ContentType'] + +def test_multipart_upload_empty(): + bucket_name = get_new_bucket() + client = get_client() + + key1 = "mymultipart" + objlen = 0 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key1, size=objlen) + e = assert_raises(ClientError, client.complete_multipart_upload,Bucket=bucket_name, Key=key1, UploadId=upload_id) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + +@pytest.mark.fails_on_dbstore +def test_multipart_upload_complete_without_create(): + bucket_name = get_new_bucket() + client = get_client() + + parts = {} + upload_opts = { + 'Bucket': bucket_name, + 'Key': 'mymultipart', + 'UploadId': 'abc1234def', + 'MultipartUpload': {'Parts': [ + {'ETag': "1234", 'PartNumber': 1} + ]} + } + e = assert_raises(ClientError, client.complete_multipart_upload, **upload_opts) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchUpload' + +@pytest.mark.fails_on_dbstore +def test_multipart_upload_small(): + bucket_name = get_new_bucket() + client = get_client() + + key1 = "mymultipart" + objlen = 1 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key1, size=objlen) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key1, UploadId=upload_id, MultipartUpload={'Parts': parts}) + response = client.get_object(Bucket=bucket_name, Key=key1) + assert response['ContentLength'] == objlen + # check extra client.complete_multipart_upload + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key1, UploadId=upload_id, MultipartUpload={'Parts': parts}) + +def _create_key_with_random_content(keyname, size=7*1024*1024, bucket_name=None, client=None): + if bucket_name is None: + bucket_name = get_new_bucket() + + if client == None: + client = get_client() + + data_str = str(next(generate_random(size, size))) + data = bytes(data_str, 'utf-8') + client.put_object(Bucket=bucket_name, Key=keyname, Body=data) + + return bucket_name + +def _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size, client=None, part_size=5*1024*1024, version_id=None): + + if(client == None): + client = get_client() + + response = client.create_multipart_upload(Bucket=dest_bucket_name, Key=dest_key) + upload_id = response['UploadId'] + + if(version_id == None): + copy_source = {'Bucket': src_bucket_name, 'Key': src_key} + else: + copy_source = {'Bucket': src_bucket_name, 'Key': src_key, 'VersionId': version_id} + + parts = [] + + i = 0 + for start_offset in range(0, size, part_size): + end_offset = min(start_offset + part_size - 1, size - 1) + part_num = i+1 + copy_source_range = 'bytes={start}-{end}'.format(start=start_offset, end=end_offset) + response = client.upload_part_copy(Bucket=dest_bucket_name, Key=dest_key, CopySource=copy_source, PartNumber=part_num, UploadId=upload_id, CopySourceRange=copy_source_range) + parts.append({'ETag': response['CopyPartResult']['ETag'], 'PartNumber': part_num}) + i = i+1 + + return (upload_id, parts) + +def _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name, version_id=None): + client = get_client() + + if(version_id == None): + response = client.get_object(Bucket=src_bucket_name, Key=src_key) + else: + response = client.get_object(Bucket=src_bucket_name, Key=src_key, VersionId=version_id) + src_size = response['ContentLength'] + + response = client.get_object(Bucket=dest_bucket_name, Key=dest_key) + dest_size = response['ContentLength'] + dest_data = _get_body(response) + assert(src_size >= dest_size) + + r = 'bytes={s}-{e}'.format(s=0, e=dest_size-1) + if(version_id == None): + response = client.get_object(Bucket=src_bucket_name, Key=src_key, Range=r) + else: + response = client.get_object(Bucket=src_bucket_name, Key=src_key, Range=r, VersionId=version_id) + src_data = _get_body(response) + assert src_data == dest_data + +@pytest.mark.fails_on_dbstore +def test_multipart_copy_small(): + src_key = 'foo' + src_bucket_name = _create_key_with_random_content(src_key) + + dest_bucket_name = get_new_bucket() + dest_key = "mymultipart" + size = 1 + client = get_client() + + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.get_object(Bucket=dest_bucket_name, Key=dest_key) + assert size == response['ContentLength'] + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + +def test_multipart_copy_invalid_range(): + client = get_client() + src_key = 'source' + src_bucket_name = _create_key_with_random_content(src_key, size=5) + + response = client.create_multipart_upload(Bucket=src_bucket_name, Key='dest') + upload_id = response['UploadId'] + + copy_source = {'Bucket': src_bucket_name, 'Key': src_key} + copy_source_range = 'bytes={start}-{end}'.format(start=0, end=21) + + e = assert_raises(ClientError, client.upload_part_copy,Bucket=src_bucket_name, Key='dest', UploadId=upload_id, CopySource=copy_source, CopySourceRange=copy_source_range, PartNumber=1) + status, error_code = _get_status_and_error_code(e.response) + valid_status = [400, 416] + if not status in valid_status: + raise AssertionError("Invalid response " + str(status)) + assert error_code == 'InvalidRange' + + +# TODO: remove fails_on_rgw when https://tracker.ceph.com/issues/40795 is resolved +@pytest.mark.fails_on_rgw +def test_multipart_copy_improper_range(): + client = get_client() + src_key = 'source' + src_bucket_name = _create_key_with_random_content(src_key, size=5) + + response = client.create_multipart_upload( + Bucket=src_bucket_name, Key='dest') + upload_id = response['UploadId'] + + copy_source = {'Bucket': src_bucket_name, 'Key': src_key} + test_ranges = ['{start}-{end}'.format(start=0, end=2), + 'bytes={start}'.format(start=0), + 'bytes=hello-world', + 'bytes=0-bar', + 'bytes=hello-', + 'bytes=0-2,3-5'] + + for test_range in test_ranges: + e = assert_raises(ClientError, client.upload_part_copy, + Bucket=src_bucket_name, Key='dest', + UploadId=upload_id, + CopySource=copy_source, + CopySourceRange=test_range, + PartNumber=1) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + + +def test_multipart_copy_without_range(): + client = get_client() + src_key = 'source' + src_bucket_name = _create_key_with_random_content(src_key, size=10) + dest_bucket_name = get_new_bucket_name() + get_new_bucket(name=dest_bucket_name) + dest_key = "mymultipartcopy" + + response = client.create_multipart_upload(Bucket=dest_bucket_name, Key=dest_key) + upload_id = response['UploadId'] + parts = [] + + copy_source = {'Bucket': src_bucket_name, 'Key': src_key} + part_num = 1 + copy_source_range = 'bytes={start}-{end}'.format(start=0, end=9) + + response = client.upload_part_copy(Bucket=dest_bucket_name, Key=dest_key, CopySource=copy_source, PartNumber=part_num, UploadId=upload_id) + + parts.append({'ETag': response['CopyPartResult']['ETag'], 'PartNumber': part_num}) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.get_object(Bucket=dest_bucket_name, Key=dest_key) + assert response['ContentLength'] == 10 + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + +@pytest.mark.fails_on_dbstore +def test_multipart_copy_special_names(): + src_bucket_name = get_new_bucket() + + dest_bucket_name = get_new_bucket() + + dest_key = "mymultipart" + size = 1 + client = get_client() + + for src_key in (' ', '_', '__', '?versionId'): + _create_key_with_random_content(src_key, bucket_name=src_bucket_name) + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + response = client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + response = client.get_object(Bucket=dest_bucket_name, Key=dest_key) + assert size == response['ContentLength'] + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + +def _check_content_using_range(key, bucket_name, data, step): + client = get_client() + response = client.get_object(Bucket=bucket_name, Key=key) + size = response['ContentLength'] + + for ofs in range(0, size, step): + toread = size - ofs + if toread > step: + toread = step + end = ofs + toread - 1 + r = 'bytes={s}-{e}'.format(s=ofs, e=end) + response = client.get_object(Bucket=bucket_name, Key=key, Range=r) + assert response['ContentLength'] == toread + body = _get_body(response) + assert body == data[ofs:end+1] + +@pytest.mark.fails_on_dbstore +def test_multipart_upload(): + bucket_name = get_new_bucket() + key="mymultipart" + content_type='text/bla' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + client = get_client() + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, content_type=content_type, metadata=metadata) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + # check extra client.complete_multipart_upload + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + response = client.get_object(Bucket=bucket_name, Key=key) + assert response['ContentType'] == content_type + assert response['Metadata'] == metadata + body = _get_body(response) + assert len(body) == response['ContentLength'] + assert body == data + + _check_content_using_range(key, bucket_name, data, 1000000) + _check_content_using_range(key, bucket_name, data, 10000000) + +def check_versioning(bucket_name, status): + client = get_client() + + try: + response = client.get_bucket_versioning(Bucket=bucket_name) + assert response['Status'] == status + except KeyError: + assert status == None + +# amazon is eventually consistent, retry a bit if failed +def check_configure_versioning_retry(bucket_name, status, expected_string): + client = get_client() + client.put_bucket_versioning(Bucket=bucket_name, VersioningConfiguration={'Status': status}) + + read_status = None + + for i in range(5): + try: + response = client.get_bucket_versioning(Bucket=bucket_name) + read_status = response['Status'] + except KeyError: + read_status = None + + if (expected_string == read_status): + break + + time.sleep(1) + + assert expected_string == read_status + +@pytest.mark.fails_on_dbstore +def test_multipart_copy_versioned(): + src_bucket_name = get_new_bucket() + dest_bucket_name = get_new_bucket() + + dest_key = "mymultipart" + check_versioning(src_bucket_name, None) + + src_key = 'foo' + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + + size = 15 * 1024 * 1024 + _create_key_with_random_content(src_key, size=size, bucket_name=src_bucket_name) + _create_key_with_random_content(src_key, size=size, bucket_name=src_bucket_name) + _create_key_with_random_content(src_key, size=size, bucket_name=src_bucket_name) + + version_id = [] + client = get_client() + response = client.list_object_versions(Bucket=src_bucket_name) + for ver in response['Versions']: + version_id.append(ver['VersionId']) + + for vid in version_id: + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size, version_id=vid) + response = client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + response = client.get_object(Bucket=dest_bucket_name, Key=dest_key) + assert size == response['ContentLength'] + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name, version_id=vid) + +def _check_upload_multipart_resend(bucket_name, key, objlen, resend_parts): + content_type = 'text/bla' + metadata = {'foo': 'bar'} + client = get_client() + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, content_type=content_type, metadata=metadata, resend_parts=resend_parts) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.get_object(Bucket=bucket_name, Key=key) + assert response['ContentType'] == content_type + assert response['Metadata'] == metadata + body = _get_body(response) + assert len(body) == response['ContentLength'] + assert body == data + + _check_content_using_range(key, bucket_name, data, 1000000) + _check_content_using_range(key, bucket_name, data, 10000000) + +@pytest.mark.fails_on_dbstore +def test_multipart_upload_resend_part(): + bucket_name = get_new_bucket() + key="mymultipart" + objlen = 30 * 1024 * 1024 + + _check_upload_multipart_resend(bucket_name, key, objlen, [0]) + _check_upload_multipart_resend(bucket_name, key, objlen, [1]) + _check_upload_multipart_resend(bucket_name, key, objlen, [2]) + _check_upload_multipart_resend(bucket_name, key, objlen, [1,2]) + _check_upload_multipart_resend(bucket_name, key, objlen, [0,1,2,3,4,5]) + +def test_multipart_upload_multiple_sizes(): + bucket_name = get_new_bucket() + key="mymultipart" + client = get_client() + + objlen = 5*1024*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + objlen = 5*1024*1024+100*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + objlen = 5*1024*1024+600*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + objlen = 10*1024*1024+100*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + objlen = 10*1024*1024+600*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + objlen = 10*1024*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + +@pytest.mark.fails_on_dbstore +def test_multipart_copy_multiple_sizes(): + src_key = 'foo' + src_bucket_name = _create_key_with_random_content(src_key, 12*1024*1024) + + dest_bucket_name = get_new_bucket() + dest_key="mymultipart" + client = get_client() + + size = 5*1024*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + + size = 5*1024*1024+100*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + + size = 5*1024*1024+600*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + + size = 10*1024*1024+100*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + + size = 10*1024*1024+600*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + + size = 10*1024*1024 + (upload_id, parts) = _multipart_copy(src_bucket_name, src_key, dest_bucket_name, dest_key, size) + client.complete_multipart_upload(Bucket=dest_bucket_name, Key=dest_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + _check_key_content(src_key, src_bucket_name, dest_key, dest_bucket_name) + +def test_multipart_upload_size_too_small(): + bucket_name = get_new_bucket() + key="mymultipart" + client = get_client() + + size = 100*1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=size, part_size=10*1024) + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'EntityTooSmall' + +def gen_rand_string(size, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + +def _do_test_multipart_upload_contents(bucket_name, key, num_parts): + payload=gen_rand_string(5)*1024*1024 + client = get_client() + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + upload_id = response['UploadId'] + + parts = [] + + for part_num in range(0, num_parts): + part = bytes(payload, 'utf-8') + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num+1, Body=part) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': part_num+1}) + + last_payload = '123'*1024*1024 + last_part = bytes(last_payload, 'utf-8') + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=num_parts+1, Body=last_part) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': num_parts+1}) + + res = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert res['ETag'] != '' + + response = client.get_object(Bucket=bucket_name, Key=key) + test_string = _get_body(response) + + all_payload = payload*num_parts + last_payload + + assert test_string == all_payload + + return all_payload + +@pytest.mark.fails_on_dbstore +def test_multipart_upload_contents(): + bucket_name = get_new_bucket() + _do_test_multipart_upload_contents(bucket_name, 'mymultipart', 3) + +def test_multipart_upload_overwrite_existing_object(): + bucket_name = get_new_bucket() + client = get_client() + key = 'mymultipart' + payload='12345'*1024*1024 + num_parts=2 + client.put_object(Bucket=bucket_name, Key=key, Body=payload) + + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + upload_id = response['UploadId'] + + parts = [] + + for part_num in range(0, num_parts): + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num+1, Body=payload) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': part_num+1}) + + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.get_object(Bucket=bucket_name, Key=key) + test_string = _get_body(response) + + assert test_string == payload*num_parts + +def test_abort_multipart_upload(): + bucket_name = get_new_bucket() + key="mymultipart" + objlen = 10 * 1024 * 1024 + client = get_client() + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen) + client.abort_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert 'Contents' not in response + +def test_abort_multipart_upload_not_found(): + bucket_name = get_new_bucket() + client = get_client() + key="mymultipart" + client.put_object(Bucket=bucket_name, Key=key) + + e = assert_raises(ClientError, client.abort_multipart_upload, Bucket=bucket_name, Key=key, UploadId='56788') + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchUpload' + +@pytest.mark.fails_on_dbstore +def test_list_multipart_upload(): + bucket_name = get_new_bucket() + client = get_client() + key="mymultipart" + mb = 1024 * 1024 + + upload_ids = [] + (upload_id1, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=5*mb) + upload_ids.append(upload_id1) + (upload_id2, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=6*mb) + upload_ids.append(upload_id2) + + key2="mymultipart2" + (upload_id3, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key2, size=5*mb) + upload_ids.append(upload_id3) + + response = client.list_multipart_uploads(Bucket=bucket_name) + uploads = response['Uploads'] + resp_uploadids = [] + + for i in range(0, len(uploads)): + resp_uploadids.append(uploads[i]['UploadId']) + + for i in range(0, len(upload_ids)): + assert True == (upload_ids[i] in resp_uploadids) + + client.abort_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id1) + client.abort_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id2) + client.abort_multipart_upload(Bucket=bucket_name, Key=key2, UploadId=upload_id3) + +@pytest.mark.fails_on_dbstore +def test_list_multipart_upload_owner(): + bucket_name = get_new_bucket() + + client1 = get_client() + user1 = get_main_user_id() + name1 = get_main_display_name() + + client2 = get_alt_client() + user2 = get_alt_user_id() + name2 = get_alt_display_name() + + # add bucket acl for public read/write access + client1.put_bucket_acl(Bucket=bucket_name, ACL='public-read-write') + + key1 = 'multipart1' + key2 = 'multipart2' + upload1 = client1.create_multipart_upload(Bucket=bucket_name, Key=key1)['UploadId'] + try: + upload2 = client2.create_multipart_upload(Bucket=bucket_name, Key=key2)['UploadId'] + try: + # match fields of an Upload from ListMultipartUploadsResult + def match(upload, key, uploadid, userid, username): + assert upload['Key'] == key + assert upload['UploadId'] == uploadid + assert upload['Initiator']['ID'] == userid + assert upload['Initiator']['DisplayName'] == username + assert upload['Owner']['ID'] == userid + assert upload['Owner']['DisplayName'] == username + + # list uploads with client1 + uploads1 = client1.list_multipart_uploads(Bucket=bucket_name)['Uploads'] + assert len(uploads1) == 2 + match(uploads1[0], key1, upload1, user1, name1) + match(uploads1[1], key2, upload2, user2, name2) + + # list uploads with client2 + uploads2 = client2.list_multipart_uploads(Bucket=bucket_name)['Uploads'] + assert len(uploads2) == 2 + match(uploads2[0], key1, upload1, user1, name1) + match(uploads2[1], key2, upload2, user2, name2) + finally: + client2.abort_multipart_upload(Bucket=bucket_name, Key=key2, UploadId=upload2) + finally: + client1.abort_multipart_upload(Bucket=bucket_name, Key=key1, UploadId=upload1) + +def test_multipart_upload_missing_part(): + bucket_name = get_new_bucket() + client = get_client() + key="mymultipart" + size = 1 + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + upload_id = response['UploadId'] + + parts = [] + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=1, Body=bytes('\x00', 'utf-8')) + # 'PartNumber should be 1' + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': 9999}) + + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + +def test_multipart_upload_incorrect_etag(): + bucket_name = get_new_bucket() + client = get_client() + key="mymultipart" + size = 1 + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + upload_id = response['UploadId'] + + parts = [] + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=1, Body=bytes('\x00', 'utf-8')) + # 'ETag' should be "93b885adfe0da089cdf634904fd59f71" + parts.append({'ETag': "ffffffffffffffffffffffffffffffff", 'PartNumber': 1}) + + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + +@pytest.mark.fails_on_dbstore +def test_multipart_get_part(): + bucket_name = get_new_bucket() + client = get_client() + key = "mymultipart" + + part_size = 5*1024*1024 + part_sizes = 3 * [part_size] + [1*1024*1024] + part_count = len(part_sizes) + total_size = sum(part_sizes) + + (upload_id, data, parts) = _multipart_upload(bucket_name, key, total_size, part_size, resend_parts=[2]) + + # request part before complete + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=1) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + res = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert len(parts) == part_count + + for part, size in zip(parts, part_sizes): + response = client.head_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber']) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + + response = client.get_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber']) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + assert response['ContentLength'] == size + # compare contents + for chunk in response['Body'].iter_chunks(): + assert chunk.decode() == data[0:len(chunk)] + data = data[len(chunk):] + + # request PartNumber out of range + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=5) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_multipart_sse_c_get_part(): + bucket_name = get_new_bucket() + client = get_client() + key = "mymultipart" + + part_size = 5*1024*1024 + part_sizes = 3 * [part_size] + [1*1024*1024] + part_count = len(part_sizes) + total_size = sum(part_sizes) + + enc_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + } + get_args = { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'SSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==', + } + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, total_size, + part_size, init_headers=enc_headers, part_headers=enc_headers, metadata=None, resend_parts=[2]) + + # request part before complete + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=1) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + res = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}, **get_args) + assert len(parts) == part_count + + for part, size in zip(parts, part_sizes): + response = client.head_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber'], **get_args) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + + response = client.get_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber'], **get_args) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + assert response['ContentLength'] == size + # compare contents + for chunk in response['Body'].iter_chunks(): + assert chunk.decode() == data[0:len(chunk)] + data = data[len(chunk):] + + # request PartNumber out of range + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=5) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + +@pytest.mark.fails_on_dbstore +def test_multipart_single_get_part(): + bucket_name = get_new_bucket() + client = get_client() + key = "mymultipart" + + part_size = 5*1024*1024 + part_sizes = [part_size] # just one part + part_count = len(part_sizes) + total_size = sum(part_sizes) + + (upload_id, data, parts) = _multipart_upload(bucket_name, key, total_size, part_size) + + # request part before complete + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=1) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + res = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert len(parts) == part_count + + for part, size in zip(parts, part_sizes): + response = client.head_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber']) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + + response = client.get_object(Bucket=bucket_name, Key=key, PartNumber=part['PartNumber']) + assert response['PartsCount'] == part_count + assert response['ETag'] == res['ETag'] + assert response['ContentLength'] == size + # compare contents + for chunk in response['Body'].iter_chunks(): + assert chunk.decode() == data[0:len(chunk)] + data = data[len(chunk):] + + # request PartNumber out of range + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=5) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + +@pytest.mark.fails_on_dbstore +def test_non_multipart_get_part(): + bucket_name = get_new_bucket() + client = get_client() + key = "singlepart" + + response = client.put_object(Bucket=bucket_name, Key=key, Body='body') + etag = response['ETag'] + + # request for PartNumber > 1 results in InvalidPart + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=2) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + + # request for PartNumber = 1 gives back the entire object + response = client.get_object(Bucket=bucket_name, Key=key, PartNumber=1) + assert response['ETag'] == etag + assert _get_body(response) == 'body' + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_non_multipart_sse_c_get_part(): + bucket_name = get_new_bucket() + client = get_client() + key = "singlepart" + + sse_args = { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'SSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + response = client.put_object(Bucket=bucket_name, Key=key, Body='body', **sse_args) + etag = response['ETag'] + + # request for PartNumber > 1 results in InvalidPart + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key, PartNumber=2, **sse_args) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidPart' + + # request for PartNumber = 1 gives back the entire object + response = client.get_object(Bucket=bucket_name, Key=key, PartNumber=1, **sse_args) + assert response['ETag'] == etag + assert _get_body(response) == 'body' + + +def _simple_http_req_100_cont(host, port, is_secure, method, resource): + """ + Send the specified request w/expect 100-continue + and await confirmation. + """ + req_str = '{method} {resource} HTTP/1.1\r\nHost: {host}\r\nAccept-Encoding: identity\r\nContent-Length: 123\r\nExpect: 100-continue\r\n\r\n'.format( + method=method, + resource=resource, + host=host, + ) + + req = bytes(req_str, 'utf-8') + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if is_secure: + ctx = ssl.create_default_context() + s = ctx.wrap_socket(s, server_hostname=host); + s.settimeout(5) + s.connect((host, port)) + s.send(req) + + try: + data = s.recv(1024) + except socket.error as msg: + print('got response: ', msg) + print('most likely server doesn\'t support 100-continue') + + s.close() + data_str = data.decode() + l = data_str.split(' ') + + assert l[0].startswith('HTTP') + + return l[1] + +def test_100_continue(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + objname='testobj' + resource = '/{bucket}/{obj}'.format(bucket=bucket_name, obj=objname) + + host = get_config_host() + port = get_config_port() + is_secure = get_config_is_secure() + + #NOTES: this test needs to be tested when is_secure is True + status = _simple_http_req_100_cont(host, port, is_secure, 'PUT', resource) + assert status == '403' + + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read-write') + + status = _simple_http_req_100_cont(host, port, is_secure, 'PUT', resource) + assert status == '100' + +def test_set_cors(): + bucket_name = get_new_bucket() + client = get_client() + allowed_methods = ['GET', 'PUT'] + allowed_origins = ['*.get', '*.put'] + + cors_config ={ + 'CORSRules': [ + {'AllowedMethods': allowed_methods, + 'AllowedOrigins': allowed_origins, + }, + ] + } + + e = assert_raises(ClientError, client.get_bucket_cors, Bucket=bucket_name) + status = _get_status(e.response) + assert status == 404 + + client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors_config) + response = client.get_bucket_cors(Bucket=bucket_name) + assert response['CORSRules'][0]['AllowedMethods'] == allowed_methods + assert response['CORSRules'][0]['AllowedOrigins'] == allowed_origins + + client.delete_bucket_cors(Bucket=bucket_name) + e = assert_raises(ClientError, client.get_bucket_cors, Bucket=bucket_name) + status = _get_status(e.response) + assert status == 404 + +def _cors_request_and_check(func, url, headers, expect_status, expect_allow_origin, expect_allow_methods): + r = func(url, headers=headers, verify=get_config_ssl_verify()) + assert r.status_code == expect_status + + assert r.headers.get('access-control-allow-origin', None) == expect_allow_origin + assert r.headers.get('access-control-allow-methods', None) == expect_allow_methods + +def test_cors_origin_response(): + bucket_name = _setup_bucket_acl(bucket_acl='public-read') + client = get_client() + + cors_config ={ + 'CORSRules': [ + {'AllowedMethods': ['GET'], + 'AllowedOrigins': ['*suffix'], + }, + {'AllowedMethods': ['GET'], + 'AllowedOrigins': ['start*end'], + }, + {'AllowedMethods': ['GET'], + 'AllowedOrigins': ['prefix*'], + }, + {'AllowedMethods': ['PUT'], + 'AllowedOrigins': ['*.put'], + } + ] + } + + e = assert_raises(ClientError, client.get_bucket_cors, Bucket=bucket_name) + status = _get_status(e.response) + assert status == 404 + + client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors_config) + + time.sleep(3) + + url = _get_post_url(bucket_name) + + _cors_request_and_check(requests.get, url, None, 200, None, None) + _cors_request_and_check(requests.get, url, {'Origin': 'foo.suffix'}, 200, 'foo.suffix', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': 'foo.bar'}, 200, None, None) + _cors_request_and_check(requests.get, url, {'Origin': 'foo.suffix.get'}, 200, None, None) + _cors_request_and_check(requests.get, url, {'Origin': 'startend'}, 200, 'startend', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': 'start1end'}, 200, 'start1end', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': 'start12end'}, 200, 'start12end', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': '0start12end'}, 200, None, None) + _cors_request_and_check(requests.get, url, {'Origin': 'prefix'}, 200, 'prefix', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': 'prefix.suffix'}, 200, 'prefix.suffix', 'GET') + _cors_request_and_check(requests.get, url, {'Origin': 'bla.prefix'}, 200, None, None) + + obj_url = '{u}/{o}'.format(u=url, o='bar') + _cors_request_and_check(requests.get, obj_url, {'Origin': 'foo.suffix'}, 404, 'foo.suffix', 'GET') + _cors_request_and_check(requests.put, obj_url, {'Origin': 'foo.suffix', 'Access-Control-Request-Method': 'GET', + 'content-length': '0'}, 403, 'foo.suffix', 'GET') + _cors_request_and_check(requests.put, obj_url, {'Origin': 'foo.suffix', 'Access-Control-Request-Method': 'PUT', + 'content-length': '0'}, 403, None, None) + + _cors_request_and_check(requests.put, obj_url, {'Origin': 'foo.suffix', 'Access-Control-Request-Method': 'DELETE', + 'content-length': '0'}, 403, None, None) + _cors_request_and_check(requests.put, obj_url, {'Origin': 'foo.suffix', 'content-length': '0'}, 403, None, None) + + _cors_request_and_check(requests.put, obj_url, {'Origin': 'foo.put', 'content-length': '0'}, 403, 'foo.put', 'PUT') + + _cors_request_and_check(requests.get, obj_url, {'Origin': 'foo.suffix'}, 404, 'foo.suffix', 'GET') + + _cors_request_and_check(requests.options, url, None, 400, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'foo.suffix'}, 400, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'bla'}, 400, None, None) + _cors_request_and_check(requests.options, obj_url, {'Origin': 'foo.suffix', 'Access-Control-Request-Method': 'GET', + 'content-length': '0'}, 200, 'foo.suffix', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': 'foo.bar', 'Access-Control-Request-Method': 'GET'}, 403, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'foo.suffix.get', 'Access-Control-Request-Method': 'GET'}, 403, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'startend', 'Access-Control-Request-Method': 'GET'}, 200, 'startend', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': 'start1end', 'Access-Control-Request-Method': 'GET'}, 200, 'start1end', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': 'start12end', 'Access-Control-Request-Method': 'GET'}, 200, 'start12end', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': '0start12end', 'Access-Control-Request-Method': 'GET'}, 403, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'prefix', 'Access-Control-Request-Method': 'GET'}, 200, 'prefix', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': 'prefix.suffix', 'Access-Control-Request-Method': 'GET'}, 200, 'prefix.suffix', 'GET') + _cors_request_and_check(requests.options, url, {'Origin': 'bla.prefix', 'Access-Control-Request-Method': 'GET'}, 403, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'foo.put', 'Access-Control-Request-Method': 'GET'}, 403, None, None) + _cors_request_and_check(requests.options, url, {'Origin': 'foo.put', 'Access-Control-Request-Method': 'PUT'}, 200, 'foo.put', 'PUT') + +def test_cors_origin_wildcard(): + bucket_name = _setup_bucket_acl(bucket_acl='public-read') + client = get_client() + + cors_config ={ + 'CORSRules': [ + {'AllowedMethods': ['GET'], + 'AllowedOrigins': ['*'], + }, + ] + } + + e = assert_raises(ClientError, client.get_bucket_cors, Bucket=bucket_name) + status = _get_status(e.response) + assert status == 404 + + client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors_config) + + time.sleep(3) + + url = _get_post_url(bucket_name) + + _cors_request_and_check(requests.get, url, None, 200, None, None) + _cors_request_and_check(requests.get, url, {'Origin': 'example.origin'}, 200, '*', 'GET') + +def test_cors_header_option(): + bucket_name = _setup_bucket_acl(bucket_acl='public-read') + client = get_client() + + cors_config ={ + 'CORSRules': [ + {'AllowedMethods': ['GET'], + 'AllowedOrigins': ['*'], + 'ExposeHeaders': ['x-amz-meta-header1'], + }, + ] + } + + e = assert_raises(ClientError, client.get_bucket_cors, Bucket=bucket_name) + status = _get_status(e.response) + assert status == 404 + + client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors_config) + + time.sleep(3) + + url = _get_post_url(bucket_name) + obj_url = '{u}/{o}'.format(u=url, o='bar') + + _cors_request_and_check(requests.options, obj_url, {'Origin': 'example.origin','Access-Control-Request-Headers':'x-amz-meta-header2','Access-Control-Request-Method':'GET'}, 403, None, None) + +def _test_cors_options_presigned_method(client, method, cannedACL=None): + bucket_name = _setup_bucket_object_acl('public-read', 'public-read', client=client) + params = {'Bucket': bucket_name, 'Key': 'foo'} + + if cannedACL is not None: + params['ACL'] = cannedACL + + if method == 'get_object': + httpMethod = 'GET' + elif method == 'put_object': + httpMethod = 'PUT' + else: + raise ValueError('invalid method') + + url = client.generate_presigned_url(ClientMethod=method, Params=params, ExpiresIn=100000, HttpMethod=httpMethod) + + res = requests.options(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 400 + + allowed_methods = [httpMethod] + allowed_origins = ['example'] + + cors_config ={ + 'CORSRules': [ + {'AllowedMethods': allowed_methods, + 'AllowedOrigins': allowed_origins, + }, + ] + } + + client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors_config) + + headers = { + 'Origin': 'example', + 'Access-Control-Request-Method': httpMethod, + } + _cors_request_and_check(requests.options, url, headers, + 200, 'example', httpMethod) + +def test_cors_presigned_get_object(): + _test_cors_options_presigned_method( + client=get_client(), + method='get_object', + ) + +def test_cors_presigned_get_object_tenant(): + _test_cors_options_presigned_method( + client=get_tenant_client(), + method='get_object', + ) + +@pytest.mark.fails_on_rgw +def test_cors_presigned_get_object_v2(): + _test_cors_options_presigned_method( + client=get_v2_client(), + method='get_object', + ) + +@pytest.mark.fails_on_rgw +def test_cors_presigned_get_object_tenant_v2(): + _test_cors_options_presigned_method( + client=get_v2_tenant_client(), + method='get_object', + ) + +def test_cors_presigned_put_object(): + _test_cors_options_presigned_method( + client=get_client(), + method='put_object', + ) + +def test_cors_presigned_put_object_with_acl(): + _test_cors_options_presigned_method( + client=get_client(), + method='put_object', + cannedACL='private', + ) + +@pytest.mark.fails_on_rgw +def test_cors_presigned_put_object_v2(): + _test_cors_options_presigned_method( + client=get_v2_client(), + method='put_object', + ) + +@pytest.mark.fails_on_rgw +def test_cors_presigned_put_object_tenant_v2(): + _test_cors_options_presigned_method( + client=get_v2_tenant_client(), + method='put_object', + ) + +def test_cors_presigned_put_object_tenant(): + _test_cors_options_presigned_method( + client=get_tenant_client(), + method='put_object', + ) + +def test_cors_presigned_put_object_tenant_with_acl(): + _test_cors_options_presigned_method( + client=get_tenant_client(), + method='put_object', + cannedACL='private', + ) + +@pytest.mark.tagging +def test_set_bucket_tagging(): + bucket_name = get_new_bucket() + client = get_client() + + tags={ + 'TagSet': [ + { + 'Key': 'Hello', + 'Value': 'World' + }, + ] + } + + e = assert_raises(ClientError, client.get_bucket_tagging, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchTagSet' + + client.put_bucket_tagging(Bucket=bucket_name, Tagging=tags) + + response = client.get_bucket_tagging(Bucket=bucket_name) + assert len(response['TagSet']) == 1 + assert response['TagSet'][0]['Key'] == 'Hello' + assert response['TagSet'][0]['Value'] == 'World' + + response = client.delete_bucket_tagging(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + e = assert_raises(ClientError, client.get_bucket_tagging, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchTagSet' + + +class FakeFile(object): + """ + file that simulates seek, tell, and current character + """ + def __init__(self, char='A', interrupt=None): + self.offset = 0 + self.char = bytes(char, 'utf-8') + self.interrupt = interrupt + + def seek(self, offset, whence=os.SEEK_SET): + if whence == os.SEEK_SET: + self.offset = offset + elif whence == os.SEEK_END: + self.offset = self.size + offset; + elif whence == os.SEEK_CUR: + self.offset += offset + + def tell(self): + return self.offset + +class FakeWriteFile(FakeFile): + """ + file that simulates interruptable reads of constant data + """ + def __init__(self, size, char='A', interrupt=None): + FakeFile.__init__(self, char, interrupt) + self.size = size + + def read(self, size=-1): + if size < 0: + size = self.size - self.offset + count = min(size, self.size - self.offset) + self.offset += count + + # Sneaky! do stuff before we return (the last time) + if self.interrupt != None and self.offset == self.size and count > 0: + self.interrupt() + + return self.char*count + +class FakeReadFile(FakeFile): + """ + file that simulates writes, interrupting after the second + """ + def __init__(self, size, char='A', interrupt=None): + FakeFile.__init__(self, char, interrupt) + self.interrupted = False + self.size = 0 + self.expected_size = size + + def write(self, chars): + assert chars == self.char*len(chars) + self.offset += len(chars) + self.size += len(chars) + + # Sneaky! do stuff on the second seek + if not self.interrupted and self.interrupt != None \ + and self.offset > 0: + self.interrupt() + self.interrupted = True + + def close(self): + assert self.size == self.expected_size + +class FakeFileVerifier(object): + """ + file that verifies expected data has been written + """ + def __init__(self, char=None): + self.char = char + self.size = 0 + + def write(self, data): + size = len(data) + if self.char == None: + self.char = data[0] + self.size += size + assert data.decode() == self.char*size + +def _verify_atomic_key_data(bucket_name, key, size=-1, char=None): + """ + Make sure file is of the expected size and (simulated) content + """ + fp_verify = FakeFileVerifier(char) + client = get_client() + client.download_fileobj(bucket_name, key, fp_verify) + if size >= 0: + assert fp_verify.size == size + +def _test_atomic_read(file_size): + """ + Create a file of A's, use it to set_contents_from_file. + Create a file of B's, use it to re-set_contents_from_file. + Re-read the contents, and confirm we get B's + """ + bucket_name = get_new_bucket() + client = get_client() + + + fp_a = FakeWriteFile(file_size, 'A') + client.put_object(Bucket=bucket_name, Key='testobj', Body=fp_a) + + fp_b = FakeWriteFile(file_size, 'B') + fp_a2 = FakeReadFile(file_size, 'A', + lambda: client.put_object(Bucket=bucket_name, Key='testobj', Body=fp_b) + ) + + read_client = get_client() + + read_client.download_fileobj(bucket_name, 'testobj', fp_a2) + fp_a2.close() + + _verify_atomic_key_data(bucket_name, 'testobj', file_size, 'B') + +def test_atomic_read_1mb(): + _test_atomic_read(1024*1024) + +def test_atomic_read_4mb(): + _test_atomic_read(1024*1024*4) + +def test_atomic_read_8mb(): + _test_atomic_read(1024*1024*8) + +def _test_atomic_write(file_size): + """ + Create a file of A's, use it to set_contents_from_file. + Verify the contents are all A's. + Create a file of B's, use it to re-set_contents_from_file. + Before re-set continues, verify content's still A's + Re-read the contents, and confirm we get B's + """ + bucket_name = get_new_bucket() + client = get_client() + objname = 'testobj' + + + # create file of A's + fp_a = FakeWriteFile(file_size, 'A') + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_a) + + + # verify A's + _verify_atomic_key_data(bucket_name, objname, file_size, 'A') + + # create file of B's + # but try to verify the file before we finish writing all the B's + fp_b = FakeWriteFile(file_size, 'B', + lambda: _verify_atomic_key_data(bucket_name, objname, file_size, 'A') + ) + + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_b) + + # verify B's + _verify_atomic_key_data(bucket_name, objname, file_size, 'B') + +def test_atomic_write_1mb(): + _test_atomic_write(1024*1024) + +def test_atomic_write_4mb(): + _test_atomic_write(1024*1024*4) + +def test_atomic_write_8mb(): + _test_atomic_write(1024*1024*8) + +def _test_atomic_dual_write(file_size): + """ + create an object, two sessions writing different contents + confirm that it is all one or the other + """ + bucket_name = get_new_bucket() + objname = 'testobj' + client = get_client() + client.put_object(Bucket=bucket_name, Key=objname) + + # write file of B's + # but before we're done, try to write all A's + fp_a = FakeWriteFile(file_size, 'A') + + def rewind_put_fp_a(): + fp_a.seek(0) + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_a) + + fp_b = FakeWriteFile(file_size, 'B', rewind_put_fp_a) + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_b) + + # verify the file + _verify_atomic_key_data(bucket_name, objname, file_size, 'B') + +def test_atomic_dual_write_1mb(): + _test_atomic_dual_write(1024*1024) + +def test_atomic_dual_write_4mb(): + _test_atomic_dual_write(1024*1024*4) + +def test_atomic_dual_write_8mb(): + _test_atomic_dual_write(1024*1024*8) + +def _test_atomic_conditional_write(file_size): + """ + Create a file of A's, use it to set_contents_from_file. + Verify the contents are all A's. + Create a file of B's, use it to re-set_contents_from_file. + Before re-set continues, verify content's still A's + Re-read the contents, and confirm we get B's + """ + bucket_name = get_new_bucket() + objname = 'testobj' + client = get_client() + + # create file of A's + fp_a = FakeWriteFile(file_size, 'A') + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_a) + + fp_b = FakeWriteFile(file_size, 'B', + lambda: _verify_atomic_key_data(bucket_name, objname, file_size, 'A') + ) + + # create file of B's + # but try to verify the file before we finish writing all the B's + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': '*'})) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_b) + + # verify B's + _verify_atomic_key_data(bucket_name, objname, file_size, 'B') + +@pytest.mark.fails_on_aws +def test_atomic_conditional_write_1mb(): + _test_atomic_conditional_write(1024*1024) + +def _test_atomic_dual_conditional_write(file_size): + """ + create an object, two sessions writing different contents + confirm that it is all one or the other + """ + bucket_name = get_new_bucket() + objname = 'testobj' + client = get_client() + + fp_a = FakeWriteFile(file_size, 'A') + response = client.put_object(Bucket=bucket_name, Key=objname, Body=fp_a) + _verify_atomic_key_data(bucket_name, objname, file_size, 'A') + etag_fp_a = response['ETag'].replace('"', '') + + # write file of C's + # but before we're done, try to write all B's + fp_b = FakeWriteFile(file_size, 'B') + lf = (lambda **kwargs: kwargs['params']['headers'].update({'If-Match': etag_fp_a})) + client.meta.events.register('before-call.s3.PutObject', lf) + def rewind_put_fp_b(): + fp_b.seek(0) + client.put_object(Bucket=bucket_name, Key=objname, Body=fp_b) + + fp_c = FakeWriteFile(file_size, 'C', rewind_put_fp_b) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=objname, Body=fp_c) + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + + # verify the file + _verify_atomic_key_data(bucket_name, objname, file_size, 'B') + +@pytest.mark.fails_on_aws +# TODO: test not passing with SSL, fix this +@pytest.mark.fails_on_rgw +def test_atomic_dual_conditional_write_1mb(): + _test_atomic_dual_conditional_write(1024*1024) + +@pytest.mark.fails_on_aws +# TODO: test not passing with SSL, fix this +@pytest.mark.fails_on_rgw +def test_atomic_write_bucket_gone(): + bucket_name = get_new_bucket() + client = get_client() + + def remove_bucket(): + client.delete_bucket(Bucket=bucket_name) + + objname = 'foo' + fp_a = FakeWriteFile(1024*1024, 'A', remove_bucket) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=objname, Body=fp_a) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchBucket' + +def test_atomic_multipart_upload_write(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + response = client.create_multipart_upload(Bucket=bucket_name, Key='foo') + upload_id = response['UploadId'] + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + + client.abort_multipart_upload(Bucket=bucket_name, Key='foo', UploadId=upload_id) + + response = client.get_object(Bucket=bucket_name, Key='foo') + body = _get_body(response) + assert body == 'bar' + +class Counter: + def __init__(self, default_val): + self.val = default_val + + def inc(self): + self.val = self.val + 1 + +class ActionOnCount: + def __init__(self, trigger_count, action): + self.count = 0 + self.trigger_count = trigger_count + self.action = action + self.result = 0 + + def trigger(self): + self.count = self.count + 1 + + if self.count == self.trigger_count: + self.result = self.action() + +def test_multipart_resend_first_finishes_last(): + bucket_name = get_new_bucket() + client = get_client() + key_name = "mymultipart" + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key_name) + upload_id = response['UploadId'] + + #file_size = 8*1024*1024 + file_size = 8 + + counter = Counter(0) + # upload_part might read multiple times from the object + # first time when it calculates md5, second time when it writes data + # out. We want to interject only on the last time, but we can't be + # sure how many times it's going to read, so let's have a test run + # and count the number of reads + + fp_dry_run = FakeWriteFile(file_size, 'C', + lambda: counter.inc() + ) + + parts = [] + + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key_name, PartNumber=1, Body=fp_dry_run) + + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': 1}) + client.complete_multipart_upload(Bucket=bucket_name, Key=key_name, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + client.delete_object(Bucket=bucket_name, Key=key_name) + + # clear parts + parts[:] = [] + + # ok, now for the actual test + fp_b = FakeWriteFile(file_size, 'B') + def upload_fp_b(): + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key_name, Body=fp_b, PartNumber=1) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': 1}) + + action = ActionOnCount(counter.val, lambda: upload_fp_b()) + + response = client.create_multipart_upload(Bucket=bucket_name, Key=key_name) + upload_id = response['UploadId'] + + fp_a = FakeWriteFile(file_size, 'A', + lambda: action.trigger() + ) + + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key_name, PartNumber=1, Body=fp_a) + + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': 1}) + client.complete_multipart_upload(Bucket=bucket_name, Key=key_name, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + _verify_atomic_key_data(bucket_name, key_name, file_size, 'A') + +@pytest.mark.fails_on_dbstore +def test_ranged_request_response_code(): + content = 'testcontent' + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + response = client.get_object(Bucket=bucket_name, Key='testobj', Range='bytes=4-7') + + fetched_content = _get_body(response) + assert fetched_content == content[4:8] + assert response['ResponseMetadata']['HTTPHeaders']['content-range'] == 'bytes 4-7/11' + assert response['ResponseMetadata']['HTTPStatusCode'] == 206 + +def _generate_random_string(size): + return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(size)) + +@pytest.mark.fails_on_dbstore +def test_ranged_big_request_response_code(): + content = _generate_random_string(8*1024*1024) + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + response = client.get_object(Bucket=bucket_name, Key='testobj', Range='bytes=3145728-5242880') + + fetched_content = _get_body(response) + assert fetched_content == content[3145728:5242881] + assert response['ResponseMetadata']['HTTPHeaders']['content-range'] == 'bytes 3145728-5242880/8388608' + assert response['ResponseMetadata']['HTTPStatusCode'] == 206 + +@pytest.mark.fails_on_dbstore +def test_ranged_request_skip_leading_bytes_response_code(): + content = 'testcontent' + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + response = client.get_object(Bucket=bucket_name, Key='testobj', Range='bytes=4-') + + fetched_content = _get_body(response) + assert fetched_content == content[4:] + assert response['ResponseMetadata']['HTTPHeaders']['content-range'] == 'bytes 4-10/11' + assert response['ResponseMetadata']['HTTPStatusCode'] == 206 + +@pytest.mark.fails_on_dbstore +def test_ranged_request_return_trailing_bytes_response_code(): + content = 'testcontent' + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + response = client.get_object(Bucket=bucket_name, Key='testobj', Range='bytes=-7') + + fetched_content = _get_body(response) + assert fetched_content == content[-7:] + assert response['ResponseMetadata']['HTTPHeaders']['content-range'] == 'bytes 4-10/11' + assert response['ResponseMetadata']['HTTPStatusCode'] == 206 + +def test_ranged_request_invalid_range(): + content = 'testcontent' + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + + # test invalid range + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='testobj', Range='bytes=40-50') + status, error_code = _get_status_and_error_code(e.response) + assert status == 416 + assert error_code == 'InvalidRange' + +def test_ranged_request_empty_object(): + content = '' + + bucket_name = get_new_bucket() + client = get_client() + + client.put_object(Bucket=bucket_name, Key='testobj', Body=content) + + # test invalid range + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='testobj', Range='bytes=40-50') + status, error_code = _get_status_and_error_code(e.response) + assert status == 416 + assert error_code == 'InvalidRange' + +def test_versioning_bucket_create_suspend(): + bucket_name = get_new_bucket() + check_versioning(bucket_name, None) + + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + +def check_obj_content(client, bucket_name, key, version_id, content): + response = client.get_object(Bucket=bucket_name, Key=key, VersionId=version_id) + if content is not None: + body = _get_body(response) + assert body == content + else: + assert response['DeleteMarker'] == True + +def check_obj_versions(client, bucket_name, key, version_ids, contents): + # check to see if objects is pointing at correct version + + response = client.list_object_versions(Bucket=bucket_name) + versions = [] + versions = response['Versions'] + # obj versions in versions come out created last to first not first to last like version_ids & contents + versions.reverse() + i = 0 + + for version in versions: + assert version['VersionId'] == version_ids[i] + assert version['Key'] == key + check_obj_content(client, bucket_name, key, version['VersionId'], contents[i]) + i += 1 + +def create_multiple_versions(client, bucket_name, key, num_versions, version_ids = None, contents = None, check_versions = True): + contents = contents or [] + version_ids = version_ids or [] + + for i in range(num_versions): + body = 'content-{i}'.format(i=i) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + version_id = response['VersionId'] + + contents.append(body) + version_ids.append(version_id) + +# if check_versions: +# check_obj_versions(client, bucket_name, key, version_ids, contents) + + return (version_ids, contents) + +def remove_obj_version(client, bucket_name, key, version_ids, contents, index): + assert len(version_ids) == len(contents) + index = index % len(version_ids) + rm_version_id = version_ids.pop(index) + rm_content = contents.pop(index) + + check_obj_content(client, bucket_name, key, rm_version_id, rm_content) + + client.delete_object(Bucket=bucket_name, Key=key, VersionId=rm_version_id) + + if len(version_ids) != 0: + check_obj_versions(client, bucket_name, key, version_ids, contents) + +def clean_up_bucket(client, bucket_name, key, version_ids): + for version_id in version_ids: + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id) + + client.delete_bucket(Bucket=bucket_name) + +def _do_test_create_remove_versions(client, bucket_name, key, num_versions, remove_start_idx, idx_inc): + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + idx = remove_start_idx + + for j in range(num_versions): + remove_obj_version(client, bucket_name, key, version_ids, contents, idx) + idx += idx_inc + + response = client.list_object_versions(Bucket=bucket_name) + if 'Versions' in response: + print(response['Versions']) + + +def test_versioning_obj_create_read_remove(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_versioning(Bucket=bucket_name, VersioningConfiguration={'MFADelete': 'Disabled', 'Status': 'Enabled'}) + key = 'testobj' + num_versions = 5 + + _do_test_create_remove_versions(client, bucket_name, key, num_versions, -1, 0) + _do_test_create_remove_versions(client, bucket_name, key, num_versions, -1, 0) + _do_test_create_remove_versions(client, bucket_name, key, num_versions, 0, 0) + _do_test_create_remove_versions(client, bucket_name, key, num_versions, 1, 0) + _do_test_create_remove_versions(client, bucket_name, key, num_versions, 4, -1) + _do_test_create_remove_versions(client, bucket_name, key, num_versions, 3, 3) + +def test_versioning_obj_create_read_remove_head(): + bucket_name = get_new_bucket() + + client = get_client() + client.put_bucket_versioning(Bucket=bucket_name, VersioningConfiguration={'MFADelete': 'Disabled', 'Status': 'Enabled'}) + key = 'testobj' + num_versions = 5 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + # removes old head object, checks new one + removed_version_id = version_ids.pop() + contents.pop() + num_versions = num_versions-1 + + response = client.delete_object(Bucket=bucket_name, Key=key, VersionId=removed_version_id) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == contents[-1] + + # add a delete marker + response = client.delete_object(Bucket=bucket_name, Key=key) + assert response['DeleteMarker'] == True + + delete_marker_version_id = response['VersionId'] + version_ids.append(delete_marker_version_id) + + response = client.list_object_versions(Bucket=bucket_name) + assert len(response['Versions']) == num_versions + assert len(response['DeleteMarkers']) == 1 + assert response['DeleteMarkers'][0]['VersionId'] == delete_marker_version_id + + clean_up_bucket(client, bucket_name, key, version_ids) + +@pytest.mark.fails_on_dbstore +def test_versioning_stack_delete_merkers(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + create_multiple_versions(client, bucket_name, "test1/a", 1) + client.delete_object(Bucket=bucket_name, Key="test1/a") + client.delete_object(Bucket=bucket_name, Key="test1/a") + client.delete_object(Bucket=bucket_name, Key="test1/a") + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + delete_markers = response['DeleteMarkers'] + assert len(versions) == 1 + assert len(delete_markers) == 3 + +def test_versioning_obj_plain_null_version_removal(): + bucket_name = get_new_bucket() + check_versioning(bucket_name, None) + + client = get_client() + key = 'testobjfoo' + content = 'fooz' + client.put_object(Bucket=bucket_name, Key=key, Body=content) + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + client.delete_object(Bucket=bucket_name, Key=key, VersionId='null') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +def test_versioning_obj_plain_null_version_overwrite(): + bucket_name = get_new_bucket() + check_versioning(bucket_name, None) + + client = get_client() + key = 'testobjfoo' + content = 'fooz' + client.put_object(Bucket=bucket_name, Key=key, Body=content) + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + content2 = 'zzz' + response = client.put_object(Bucket=bucket_name, Key=key, Body=content2) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == content2 + + version_id = response['VersionId'] + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == content + + client.delete_object(Bucket=bucket_name, Key=key, VersionId='null') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +def test_versioning_obj_plain_null_version_overwrite_suspended(): + bucket_name = get_new_bucket() + check_versioning(bucket_name, None) + + client = get_client() + key = 'testobjbar' + content = 'foooz' + client.put_object(Bucket=bucket_name, Key=key, Body=content) + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + + content2 = 'zzz' + response = client.put_object(Bucket=bucket_name, Key=key, Body=content2) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == content2 + + response = client.list_object_versions(Bucket=bucket_name) + # original object with 'null' version id still counts as a version + assert len(response['Versions']) == 1 + + client.delete_object(Bucket=bucket_name, Key=key, VersionId='null') + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +def delete_suspended_versioning_obj(client, bucket_name, key, version_ids, contents): + client.delete_object(Bucket=bucket_name, Key=key) + + # clear out old null objects in lists since they will get overwritten + assert len(version_ids) == len(contents) + i = 0 + for version_id in version_ids: + if version_id == 'null': + version_ids.pop(i) + contents.pop(i) + i += 1 + + return (version_ids, contents) + +def overwrite_suspended_versioning_obj(client, bucket_name, key, version_ids, contents, content): + client.put_object(Bucket=bucket_name, Key=key, Body=content) + + # clear out old null objects in lists since they will get overwritten + assert len(version_ids) == len(contents) + i = 0 + for version_id in version_ids: + if version_id == 'null': + version_ids.pop(i) + contents.pop(i) + i += 1 + + # add new content with 'null' version id to the end + contents.append(content) + version_ids.append('null') + + return (version_ids, contents) + + +def test_versioning_obj_suspend_versions(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'testobj' + num_versions = 5 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + + delete_suspended_versioning_obj(client, bucket_name, key, version_ids, contents) + delete_suspended_versioning_obj(client, bucket_name, key, version_ids, contents) + + overwrite_suspended_versioning_obj(client, bucket_name, key, version_ids, contents, 'null content 1') + overwrite_suspended_versioning_obj(client, bucket_name, key, version_ids, contents, 'null content 2') + delete_suspended_versioning_obj(client, bucket_name, key, version_ids, contents) + overwrite_suspended_versioning_obj(client, bucket_name, key, version_ids, contents, 'null content 3') + delete_suspended_versioning_obj(client, bucket_name, key, version_ids, contents) + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, 3, version_ids, contents) + num_versions += 3 + + for idx in range(num_versions): + remove_obj_version(client, bucket_name, key, version_ids, contents, idx) + + assert len(version_ids) == 0 + assert len(version_ids) == len(contents) + +@pytest.mark.fails_on_dbstore +def test_versioning_obj_suspended_copy(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key1 = 'testobj1' + num_versions = 1 + (version_ids, contents) = create_multiple_versions(client, bucket_name, key1, num_versions) + + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + + content = 'null content' + overwrite_suspended_versioning_obj(client, bucket_name, key1, version_ids, contents, content) + + # copy to another object + key2 = 'testobj2' + copy_source = {'Bucket': bucket_name, 'Key': key1} + client.copy_object(Bucket=bucket_name, Key=key2, CopySource=copy_source) + + # copy to another non-versioned bucket + bucket_name2 = get_new_bucket() + copy_source = {'Bucket': bucket_name, 'Key': key1} + client.copy_object(Bucket=bucket_name2, Key=key1, CopySource=copy_source) + + # delete the source object. keep the 'null' entry in version_ids + client.delete_object(Bucket=bucket_name, Key=key1) + + # get the target object + response = client.get_object(Bucket=bucket_name, Key=key2) + body = _get_body(response) + assert body == content + + # get the target object from the other bucket + response = client.get_object(Bucket=bucket_name2, Key=key1) + body = _get_body(response) + assert body == content + + # cleaning up + client.delete_object(Bucket=bucket_name, Key=key2) + client.delete_object(Bucket=bucket_name, Key=key2, VersionId='null') + clean_up_bucket(client, bucket_name, key1, version_ids) + + client.delete_object(Bucket=bucket_name2, Key=key1) + client.delete_bucket(Bucket=bucket_name2) + +def test_versioning_obj_create_versions_remove_all(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'testobj' + num_versions = 10 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + for idx in range(num_versions): + remove_obj_version(client, bucket_name, key, version_ids, contents, idx) + + assert len(version_ids) == 0 + assert len(version_ids) == len(contents) + +def test_versioning_obj_create_versions_remove_special_names(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + keys = ['_testobj', '_', ':', ' '] + num_versions = 10 + + for key in keys: + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + for idx in range(num_versions): + remove_obj_version(client, bucket_name, key, version_ids, contents, idx) + + assert len(version_ids) == 0 + assert len(version_ids) == len(contents) + +@pytest.mark.fails_on_dbstore +def test_versioning_obj_create_overwrite_multipart(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'testobj' + num_versions = 3 + contents = [] + version_ids = [] + + for i in range(num_versions): + ret = _do_test_multipart_upload_contents(bucket_name, key, 3) + contents.append(ret) + + response = client.list_object_versions(Bucket=bucket_name) + for version in response['Versions']: + version_ids.append(version['VersionId']) + + version_ids.reverse() + check_obj_versions(client, bucket_name, key, version_ids, contents) + + for idx in range(num_versions): + remove_obj_version(client, bucket_name, key, version_ids, contents, idx) + + assert len(version_ids) == 0 + assert len(version_ids) == len(contents) + +def test_versioning_obj_list_marker(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'testobj' + key2 = 'testobj-1' + num_versions = 5 + + contents = [] + version_ids = [] + contents2 = [] + version_ids2 = [] + + # for key #1 + for i in range(num_versions): + body = 'content-{i}'.format(i=i) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + version_id = response['VersionId'] + + contents.append(body) + version_ids.append(version_id) + + # for key #2 + for i in range(num_versions): + body = 'content-{i}'.format(i=i) + response = client.put_object(Bucket=bucket_name, Key=key2, Body=body) + version_id = response['VersionId'] + + contents2.append(body) + version_ids2.append(version_id) + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + # obj versions in versions come out created last to first not first to last like version_ids & contents + versions.reverse() + + i = 0 + # test the last 5 created objects first + for i in range(5): + version = versions[i] + assert version['VersionId'] == version_ids2[i] + assert version['Key'] == key2 + check_obj_content(client, bucket_name, key2, version['VersionId'], contents2[i]) + i += 1 + + # then the first 5 + for j in range(5): + version = versions[i] + assert version['VersionId'] == version_ids[j] + assert version['Key'] == key + check_obj_content(client, bucket_name, key, version['VersionId'], contents[j]) + i += 1 + +@pytest.mark.fails_on_dbstore +def test_versioning_copy_obj_version(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'testobj' + num_versions = 3 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + for i in range(num_versions): + new_key_name = 'key_{i}'.format(i=i) + copy_source = {'Bucket': bucket_name, 'Key': key, 'VersionId': version_ids[i]} + client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key=new_key_name) + response = client.get_object(Bucket=bucket_name, Key=new_key_name) + body = _get_body(response) + assert body == contents[i] + + another_bucket_name = get_new_bucket() + + for i in range(num_versions): + new_key_name = 'key_{i}'.format(i=i) + copy_source = {'Bucket': bucket_name, 'Key': key, 'VersionId': version_ids[i]} + client.copy_object(Bucket=another_bucket_name, CopySource=copy_source, Key=new_key_name) + response = client.get_object(Bucket=another_bucket_name, Key=new_key_name) + body = _get_body(response) + assert body == contents[i] + + new_key_name = 'new_key' + copy_source = {'Bucket': bucket_name, 'Key': key} + client.copy_object(Bucket=another_bucket_name, CopySource=copy_source, Key=new_key_name) + + response = client.get_object(Bucket=another_bucket_name, Key=new_key_name) + body = _get_body(response) + assert body == contents[-1] + +def test_versioning_multi_object_delete(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'key' + num_versions = 2 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + assert len(version_ids) == 2 + + # delete both versions + objects = [{'Key': key, 'VersionId': v} for v in version_ids] + client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects}) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + + # now remove again, should all succeed due to idempotency + client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects}) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +def test_versioning_multi_object_delete_with_marker(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'key' + num_versions = 2 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + assert len(version_ids) == num_versions + objects = [{'Key': key, 'VersionId': v} for v in version_ids] + + # create a delete marker + response = client.delete_object(Bucket=bucket_name, Key=key) + assert response['DeleteMarker'] + objects += [{'Key': key, 'VersionId': response['VersionId']}] + + # delete all versions + client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects}) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + assert not 'DeleteMarkers' in response + + # now remove again, should all succeed due to idempotency + client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects}) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + assert not 'DeleteMarkers' in response + +@pytest.mark.fails_on_dbstore +def test_versioning_multi_object_delete_with_marker_create(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'key' + + # use delete_objects() to create a delete marker + response = client.delete_objects(Bucket=bucket_name, Delete={'Objects': [{'Key': key}]}) + assert len(response['Deleted']) == 1 + assert response['Deleted'][0]['DeleteMarker'] + delete_marker_version_id = response['Deleted'][0]['DeleteMarkerVersionId'] + + response = client.list_object_versions(Bucket=bucket_name) + delete_markers = response['DeleteMarkers'] + + assert len(delete_markers) == 1 + assert delete_marker_version_id == delete_markers[0]['VersionId'] + assert key == delete_markers[0]['Key'] + +def test_versioned_object_acl(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'xyz' + num_versions = 3 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + version_id = version_ids[1] + + response = client.get_object_acl(Bucket=bucket_name, Key=key, VersionId=version_id) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + assert response['Owner']['DisplayName'] == display_name + assert response['Owner']['ID'] == user_id + + grants = response['Grants'] + default_policy = [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ] + + check_grants(grants, default_policy) + + client.put_object_acl(ACL='public-read',Bucket=bucket_name, Key=key, VersionId=version_id) + + response = client.get_object_acl(Bucket=bucket_name, Key=key, VersionId=version_id) + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + + client.put_object(Bucket=bucket_name, Key=key) + + response = client.get_object_acl(Bucket=bucket_name, Key=key) + grants = response['Grants'] + check_grants(grants, default_policy) + +@pytest.mark.fails_on_dbstore +def test_versioned_object_acl_no_version_specified(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'xyz' + num_versions = 3 + + (version_ids, contents) = create_multiple_versions(client, bucket_name, key, num_versions) + + response = client.get_object(Bucket=bucket_name, Key=key) + version_id = response['VersionId'] + + response = client.get_object_acl(Bucket=bucket_name, Key=key, VersionId=version_id) + + display_name = get_main_display_name() + user_id = get_main_user_id() + + assert response['Owner']['DisplayName'] == display_name + assert response['Owner']['ID'] == user_id + + grants = response['Grants'] + default_policy = [ + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ] + + check_grants(grants, default_policy) + + client.put_object_acl(ACL='public-read',Bucket=bucket_name, Key=key) + + response = client.get_object_acl(Bucket=bucket_name, Key=key, VersionId=version_id) + grants = response['Grants'] + check_grants( + grants, + [ + dict( + Permission='READ', + ID=None, + DisplayName=None, + URI='http://acs.amazonaws.com/groups/global/AllUsers', + EmailAddress=None, + Type='Group', + ), + dict( + Permission='FULL_CONTROL', + ID=user_id, + DisplayName=display_name, + URI=None, + EmailAddress=None, + Type='CanonicalUser', + ), + ], + ) + +def _do_create_object(client, bucket_name, key, i): + body = 'data {i}'.format(i=i) + client.put_object(Bucket=bucket_name, Key=key, Body=body) + +def _do_remove_ver(client, bucket_name, key, version_id): + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id) + +def _do_create_versioned_obj_concurrent(client, bucket_name, key, num): + t = [] + for i in range(num): + thr = threading.Thread(target = _do_create_object, args=(client, bucket_name, key, i)) + thr.start() + t.append(thr) + return t + +def _do_clear_versioned_bucket_concurrent(client, bucket_name): + t = [] + response = client.list_object_versions(Bucket=bucket_name) + for version in response.get('Versions', []): + thr = threading.Thread(target = _do_remove_ver, args=(client, bucket_name, version['Key'], version['VersionId'])) + thr.start() + t.append(thr) + return t + +def test_versioned_concurrent_object_create_concurrent_remove(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'myobj' + num_versions = 5 + + for i in range(5): + t = _do_create_versioned_obj_concurrent(client, bucket_name, key, num_versions) + _do_wait_completion(t) + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + + assert len(versions) == num_versions + + t = _do_clear_versioned_bucket_concurrent(client, bucket_name) + _do_wait_completion(t) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +def test_versioned_concurrent_object_create_and_remove(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + key = 'myobj' + num_versions = 3 + + all_threads = [] + + for i in range(3): + + t = _do_create_versioned_obj_concurrent(client, bucket_name, key, num_versions) + all_threads.append(t) + + t = _do_clear_versioned_bucket_concurrent(client, bucket_name) + all_threads.append(t) + + for t in all_threads: + _do_wait_completion(t) + + t = _do_clear_versioned_bucket_concurrent(client, bucket_name) + _do_wait_completion(t) + + response = client.list_object_versions(Bucket=bucket_name) + assert not 'Versions' in response + +@pytest.mark.lifecycle +def test_lifecycle_set(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Days': 1}, 'Prefix': 'test1/', 'Status':'Enabled'}, + {'ID': 'rule2', 'Expiration': {'Days': 2}, 'Prefix': 'test2/', 'Status':'Disabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +def test_lifecycle_get(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'test1/', 'Expiration': {'Days': 31}, 'Prefix': 'test1/', 'Status':'Enabled'}, + {'ID': 'test2/', 'Expiration': {'Days': 120}, 'Prefix': 'test2/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + response = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + assert response['Rules'] == rules + +@pytest.mark.lifecycle +def test_lifecycle_delete(): + client = get_client() + bucket_name = get_new_bucket(client) + + e = assert_raises(ClientError, client.get_bucket_lifecycle_configuration, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchLifecycleConfiguration' + + # returns 204 even if there is no lifecycle config + response = client.delete_bucket_lifecycle(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + rules=[{'ID': 'test1/', 'Expiration': {'Days': 31}, 'Prefix': 'test1/', 'Status':'Enabled'}, + {'ID': 'test2/', 'Expiration': {'Days': 120}, 'Prefix': 'test2/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + + response = client.delete_bucket_lifecycle(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + e = assert_raises(ClientError, client.get_bucket_lifecycle_configuration, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchLifecycleConfiguration' + + # returns 204 even if there is no lifecycle config + response = client.delete_bucket_lifecycle(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + +@pytest.mark.lifecycle +def test_lifecycle_get_no_id(): + bucket_name = get_new_bucket() + client = get_client() + + rules=[{'Expiration': {'Days': 31}, 'Prefix': 'test1/', 'Status':'Enabled'}, + {'Expiration': {'Days': 120}, 'Prefix': 'test2/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + response = client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + current_lc = response['Rules'] + + Rule = namedtuple('Rule',['prefix','status','days']) + rules = {'rule1' : Rule('test1/','Enabled',31), + 'rule2' : Rule('test2/','Enabled',120)} + + for lc_rule in current_lc: + if lc_rule['Prefix'] == rules['rule1'].prefix: + assert lc_rule['Expiration']['Days'] == rules['rule1'].days + assert lc_rule['Status'] == rules['rule1'].status + assert 'ID' in lc_rule + elif lc_rule['Prefix'] == rules['rule2'].prefix: + assert lc_rule['Expiration']['Days'] == rules['rule2'].days + assert lc_rule['Status'] == rules['rule2'].status + assert 'ID' in lc_rule + else: + # neither of the rules we supplied was returned, something wrong + print("rules not right") + assert False + +# The test harness for lifecycle is configured to treat days as 10 second intervals. +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration(): + bucket_name = _create_objects(keys=['expire1/foo', 'expire1/bar', 'keep2/foo', + 'keep2/bar', 'expire3/foo', 'expire3/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Days': 1}, 'Prefix': 'expire1/', 'Status':'Enabled'}, + {'ID': 'rule2', 'Expiration': {'Days': 5}, 'Prefix': 'expire3/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + response = client.list_objects(Bucket=bucket_name) + init_objects = response['Contents'] + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + response = client.list_objects(Bucket=bucket_name) + expire1_objects = response['Contents'] + + time.sleep(lc_interval) + response = client.list_objects(Bucket=bucket_name) + keep2_objects = response['Contents'] + + time.sleep(3*lc_interval) + response = client.list_objects(Bucket=bucket_name) + expire3_objects = response['Contents'] + + assert len(init_objects) == 6 + assert len(expire1_objects) == 4 + assert len(keep2_objects) == 4 + assert len(expire3_objects) == 2 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.list_objects_v2 +@pytest.mark.fails_on_dbstore +def test_lifecyclev2_expiration(): + bucket_name = _create_objects(keys=['expire1/foo', 'expire1/bar', 'keep2/foo', + 'keep2/bar', 'expire3/foo', 'expire3/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Days': 1}, 'Prefix': 'expire1/', 'Status':'Enabled'}, + {'ID': 'rule2', 'Expiration': {'Days': 5}, 'Prefix': 'expire3/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + response = client.list_objects_v2(Bucket=bucket_name) + init_objects = response['Contents'] + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + response = client.list_objects_v2(Bucket=bucket_name) + expire1_objects = response['Contents'] + + time.sleep(lc_interval) + response = client.list_objects_v2(Bucket=bucket_name) + keep2_objects = response['Contents'] + + time.sleep(3*lc_interval) + response = client.list_objects_v2(Bucket=bucket_name) + expire3_objects = response['Contents'] + + assert len(init_objects) == 6 + assert len(expire1_objects) == 4 + assert len(keep2_objects) == 4 + assert len(expire3_objects) == 2 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +def test_lifecycle_expiration_versioning_enabled(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + create_multiple_versions(client, bucket_name, "test1/a", 1) + client.delete_object(Bucket=bucket_name, Key="test1/a") + + rules=[{'ID': 'rule1', 'Expiration': {'Days': 1}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + delete_markers = response['DeleteMarkers'] + assert len(versions) == 1 + assert len(delete_markers) == 1 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +def test_lifecycle_expiration_tags1(): + bucket_name = get_new_bucket() + client = get_client() + + tom_key = 'days1/tom' + tom_tagset = {'TagSet': + [{'Key': 'tom', 'Value': 'sawyer'}]} + + client.put_object(Bucket=bucket_name, Key=tom_key, Body='tom_body') + + response = client.put_object_tagging(Bucket=bucket_name, Key=tom_key, + Tagging=tom_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lifecycle_config = { + 'Rules': [ + { + 'Expiration': { + 'Days': 1, + }, + 'ID': 'rule_tag1', + 'Filter': { + 'Prefix': 'days1/', + 'Tag': { + 'Key': 'tom', + 'Value': 'sawyer' + }, + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + + try: + expire_objects = response['Contents'] + except KeyError: + expire_objects = [] + + assert len(expire_objects) == 0 + +# factor out common setup code +def setup_lifecycle_tags2(client, bucket_name): + tom_key = 'days1/tom' + tom_tagset = {'TagSet': + [{'Key': 'tom', 'Value': 'sawyer'}]} + + client.put_object(Bucket=bucket_name, Key=tom_key, Body='tom_body') + + response = client.put_object_tagging(Bucket=bucket_name, Key=tom_key, + Tagging=tom_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + huck_key = 'days1/huck' + huck_tagset = { + 'TagSet': + [{'Key': 'tom', 'Value': 'sawyer'}, + {'Key': 'huck', 'Value': 'finn'}]} + + client.put_object(Bucket=bucket_name, Key=huck_key, Body='huck_body') + + response = client.put_object_tagging(Bucket=bucket_name, Key=huck_key, + Tagging=huck_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lifecycle_config = { + 'Rules': [ + { + 'Expiration': { + 'Days': 1, + }, + 'ID': 'rule_tag1', + 'Filter': { + 'Prefix': 'days1/', + 'Tag': { + 'Key': 'tom', + 'Value': 'sawyer' + }, + 'And': { + 'Prefix': 'days1', + 'Tags': [ + { + 'Key': 'huck', + 'Value': 'finn' + }, + ] + } + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + return response + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_tags2(): + bucket_name = get_new_bucket() + client = get_client() + + response = setup_lifecycle_tags2(client, bucket_name) + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + response = client.list_objects(Bucket=bucket_name) + expire1_objects = response['Contents'] + + assert len(expire1_objects) == 1 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_versioned_tags2(): + bucket_name = get_new_bucket() + client = get_client() + + # mix in versioning + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + response = setup_lifecycle_tags2(client, bucket_name) + + lc_interval = get_lc_debug_interval() + + time.sleep(3*lc_interval) + response = client.list_objects(Bucket=bucket_name) + expire1_objects = response['Contents'] + + assert len(expire1_objects) == 1 + +# setup for scenario based on vidushi mishra's in rhbz#1877737 +def setup_lifecycle_noncur_tags(client, bucket_name, days): + + # first create and tag the objects (10 versions of 1) + key = "myobject_" + tagset = {'TagSet': + [{'Key': 'vidushi', 'Value': 'mishra'}]} + + for ix in range(10): + body = "%s v%d" % (key, ix) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_object_tagging(Bucket=bucket_name, Key=key, + Tagging=tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lifecycle_config = { + 'Rules': [ + { + 'NoncurrentVersionExpiration': { + 'NoncurrentDays': days, + }, + 'ID': 'rule_tag1', + 'Filter': { + 'Prefix': '', + 'Tag': { + 'Key': 'vidushi', + 'Value': 'mishra' + }, + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + return response + +def verify_lifecycle_expiration_noncur_tags(client, bucket_name, secs): + time.sleep(secs) + try: + response = client.list_object_versions(Bucket=bucket_name) + objs_list = response['Versions'] + except: + objs_list = [] + return len(objs_list) + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_noncur_tags1(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + # create 10 object versions (9 noncurrent) and a tag-filter + # noncurrent version expiration at 4 "days" + response = setup_lifecycle_noncur_tags(client, bucket_name, 4) + + lc_interval = get_lc_debug_interval() + + num_objs = verify_lifecycle_expiration_noncur_tags( + client, bucket_name, 2*lc_interval) + + # at T+20, 10 objects should exist + assert num_objs == 10 + + num_objs = verify_lifecycle_expiration_noncur_tags( + client, bucket_name, 5*lc_interval) + + # at T+60, only the current object version should exist + assert num_objs == 1 + +def wait_interval_list_object_versions(client, bucket_name, secs): + time.sleep(secs) + try: + response = client.list_object_versions(Bucket=bucket_name) + objs_list = response['Versions'] + except: + objs_list = [] + return len(objs_list) + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_newer_noncurrent(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + # create 10 object versions (9 noncurrent) + key = "myobject_" + + for ix in range(10): + body = "%s v%d" % (key, ix) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # add a lifecycle rule which sets newer-noncurrent-versions to 5 + days = 1 + lifecycle_config = { + 'Rules': [ + { + 'NoncurrentVersionExpiration': { + 'NoncurrentDays': days, + 'NewerNoncurrentVersions': 5, + }, + 'ID': 'newer_noncurrent1', + 'Filter': { + 'Prefix': '', + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lc_interval = get_lc_debug_interval() + + num_objs = wait_interval_list_object_versions( + client, bucket_name, 2*lc_interval) + + # at T+20, 6 objects should exist (1 current and (9 - 5) noncurrent) + assert num_objs == 6 + +def get_byte_buffer(nbytes): + buf = BytesIO(b"") + for x in range(nbytes): + buf.write(b"b") + buf.seek(0) + return buf + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_size_gt(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + # create one object lt and one object gt 2000 bytes + key = "myobject_small" + body = get_byte_buffer(1000) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + key = "myobject_big" + body = get_byte_buffer(3000) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # add a lifecycle rule which expires objects greater than 2000 bytes + days = 1 + lifecycle_config = { + 'Rules': [ + { + 'Expiration': { + 'Days': days + }, + 'ID': 'object_gt1', + 'Filter': { + 'Prefix': '', + 'ObjectSizeGreaterThan': 2000 + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lc_interval = get_lc_debug_interval() + time.sleep(10*lc_interval) + + # we should find only the small object present + response = client.list_objects(Bucket=bucket_name) + objects = response['Contents'] + + assert len(objects) == 1 + assert objects[0]['Key'] == "myobject_small" + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_size_lt(): + bucket_name = get_new_bucket() + client = get_client() + + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + # create one object lt and one object gt 2000 bytes + key = "myobject_small" + body = get_byte_buffer(1000) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + key = "myobject_big" + body = get_byte_buffer(3000) + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # add a lifecycle rule which expires objects greater than 2000 bytes + days = 1 + lifecycle_config = { + 'Rules': [ + { + 'Expiration': { + 'Days': days + }, + 'ID': 'object_lt1', + 'Filter': { + 'Prefix': '', + 'ObjectSizeLessThan': 2000 + }, + 'Status': 'Enabled', + }, + ] + } + + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle_config) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lc_interval = get_lc_debug_interval() + time.sleep(2*lc_interval) + + # we should find only the large object present + response = client.list_objects(Bucket=bucket_name) + objects = response['Contents'] + + assert len(objects) == 1 + assert objects[0]['Key'] == "myobject_big" + +@pytest.mark.lifecycle +def test_lifecycle_id_too_long(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 256*'a', 'Expiration': {'Days': 2}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.lifecycle +def test_lifecycle_same_id(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Days': 1}, 'Prefix': 'test1/', 'Status':'Enabled'}, + {'ID': 'rule1', 'Expiration': {'Days': 2}, 'Prefix': 'test2/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.lifecycle +def test_lifecycle_invalid_status(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Days': 2}, 'Prefix': 'test1/', 'Status':'enabled'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + rules=[{'ID': 'rule1', 'Expiration': {'Days': 2}, 'Prefix': 'test1/', 'Status':'disabled'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + rules=[{'ID': 'rule1', 'Expiration': {'Days': 2}, 'Prefix': 'test1/', 'Status':'invalid'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + +@pytest.mark.lifecycle +def test_lifecycle_set_date(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Date': '2017-09-27'}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +def test_lifecycle_set_invalid_date(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Date': '20200101'}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_date(): + bucket_name = _create_objects(keys=['past/foo', 'future/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Date': '2015-01-01'}, 'Prefix': 'past/', 'Status':'Enabled'}, + {'ID': 'rule2', 'Expiration': {'Date': '2030-01-01'}, 'Prefix': 'future/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + response = client.list_objects(Bucket=bucket_name) + init_objects = response['Contents'] + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(3*lc_interval) + response = client.list_objects(Bucket=bucket_name) + expire_objects = response['Contents'] + + assert len(init_objects) == 2 + assert len(expire_objects) == 1 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +def test_lifecycle_expiration_days0(): + bucket_name = _create_objects(keys=['days0/foo', 'days0/bar']) + client = get_client() + + rules=[{'Expiration': {'Days': 0}, 'ID': 'rule1', 'Prefix': 'days0/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + + # days: 0 is legal in a transition rule, but not legal in an + # expiration rule + response_code = "" + try: + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + except botocore.exceptions.ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'InvalidArgument' + + +def setup_lifecycle_expiration(client, bucket_name, rule_id, delta_days, + rule_prefix): + rules=[{'ID': rule_id, + 'Expiration': {'Days': delta_days}, 'Prefix': rule_prefix, + 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + key = rule_prefix + 'foo' + body = 'bar' + response = client.put_object(Bucket=bucket_name, Key=key, Body=body) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + return response + +def check_lifecycle_expiration_header(response, start_time, rule_id, + delta_days): + expr_exists = ('x-amz-expiration' in response['ResponseMetadata']['HTTPHeaders']) + if (not expr_exists): + return False + expr_hdr = response['ResponseMetadata']['HTTPHeaders']['x-amz-expiration'] + + m = re.search(r'expiry-date="(.+)", rule-id="(.+)"', expr_hdr) + + expiration = dateutil.parser.parse(m.group(1)) + days_to_expire = ((expiration.replace(tzinfo=None) - start_time).days == delta_days) + rule_eq_id = (m.group(2) == rule_id) + + return days_to_expire and rule_eq_id + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +def test_lifecycle_expiration_header_put(): + bucket_name = get_new_bucket() + client = get_client() + + now = datetime.datetime.utcnow() + response = setup_lifecycle_expiration( + client, bucket_name, 'rule1', 1, 'days1/') + assert check_lifecycle_expiration_header(response, now, 'rule1', 1) + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_header_head(): + bucket_name = get_new_bucket() + client = get_client() + + now = datetime.datetime.utcnow() + response = setup_lifecycle_expiration( + client, bucket_name, 'rule1', 1, 'days1/') + + key = 'days1/' + 'foo' + + # stat the object, check header + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert check_lifecycle_expiration_header(response, now, 'rule1', 1) + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_header_tags_head(): + bucket_name = get_new_bucket() + client = get_client() + lifecycle={ + "Rules": [ + { + "Filter": { + "Tag": {"Key": "key1", "Value": "tag1"} + }, + "Status": "Enabled", + "Expiration": { + "Days": 1 + }, + "ID": "rule1" + }, + ] + } + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle) + key1 = "obj_key1" + body1 = "obj_key1_body" + tags1={'TagSet': [{'Key': 'key1', 'Value': 'tag1'}, + {'Key': 'key5','Value': 'tag5'}]} + response = client.put_object(Bucket=bucket_name, Key=key1, Body=body1) + response = client.put_object_tagging(Bucket=bucket_name, Key=key1,Tagging=tags1) + + # stat the object, check header + response = client.head_object(Bucket=bucket_name, Key=key1) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert check_lifecycle_expiration_header(response, datetime.datetime.now(None), 'rule1', 1) + + # test that header is not returning when it should not + lifecycle={ + "Rules": [ + { + "Filter": { + "Tag": {"Key": "key2", "Value": "tag1"} + }, + "Status": "Enabled", + "Expiration": { + "Days": 1 + }, + "ID": "rule1" + }, + ] + } + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle) + # stat the object, check header + response = client.head_object(Bucket=bucket_name, Key=key1) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert check_lifecycle_expiration_header(response, datetime.datetime.now(None), 'rule1', 1) == False + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_dbstore +def test_lifecycle_expiration_header_and_tags_head(): + now = datetime.datetime.utcnow() + bucket_name = get_new_bucket() + client = get_client() + lifecycle={ + "Rules": [ + { + "Filter": { + "And": { + "Tags": [ + { + "Key": "key1", + "Value": "tag1" + }, + { + "Key": "key5", + "Value": "tag6" + } + ] + } + }, + "Status": "Enabled", + "Expiration": { + "Days": 1 + }, + "ID": "rule1" + }, + ] + } + response = client.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lifecycle) + key1 = "obj_key1" + body1 = "obj_key1_body" + tags1={'TagSet': [{'Key': 'key1', 'Value': 'tag1'}, + {'Key': 'key5','Value': 'tag5'}]} + response = client.put_object(Bucket=bucket_name, Key=key1, Body=body1) + response = client.put_object_tagging(Bucket=bucket_name, Key=key1,Tagging=tags1) + + # stat the object, check header + response = client.head_object(Bucket=bucket_name, Key=key1) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert check_lifecycle_expiration_header(response, datetime.datetime.now(None), 'rule1', 1) == False + +@pytest.mark.lifecycle +def test_lifecycle_set_noncurrent(): + bucket_name = _create_objects(keys=['past/foo', 'future/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'NoncurrentVersionExpiration': {'NoncurrentDays': 2}, 'Prefix': 'past/', 'Status':'Enabled'}, + {'ID': 'rule2', 'NoncurrentVersionExpiration': {'NoncurrentDays': 3}, 'Prefix': 'future/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_noncur_expiration(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + create_multiple_versions(client, bucket_name, "test1/a", 3) + # not checking the object contents on the second run, because the function doesn't support multiple checks + create_multiple_versions(client, bucket_name, "test2/abc", 3, check_versions=False) + + response = client.list_object_versions(Bucket=bucket_name) + init_versions = response['Versions'] + + rules=[{'ID': 'rule1', 'NoncurrentVersionExpiration': {'NoncurrentDays': 2}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(5*lc_interval) + + response = client.list_object_versions(Bucket=bucket_name) + expire_versions = response['Versions'] + assert len(init_versions) == 6 + assert len(expire_versions) == 4 + +@pytest.mark.lifecycle +def test_lifecycle_set_deletemarker(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'ExpiredObjectDeleteMarker': True}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +def test_lifecycle_set_filter(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'ExpiredObjectDeleteMarker': True}, 'Filter': {'Prefix': 'foo'}, 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +def test_lifecycle_set_empty_filter(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'ExpiredObjectDeleteMarker': True}, 'Filter': {}, 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_deletemarker_expiration(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + create_multiple_versions(client, bucket_name, "test1/a", 1) + create_multiple_versions(client, bucket_name, "test2/abc", 1, check_versions=False) + client.delete_object(Bucket=bucket_name, Key="test1/a") + client.delete_object(Bucket=bucket_name, Key="test2/abc") + + response = client.list_object_versions(Bucket=bucket_name) + init_versions = response['Versions'] + deleted_versions = response['DeleteMarkers'] + total_init_versions = init_versions + deleted_versions + + rules=[{'ID': 'rule1', 'NoncurrentVersionExpiration': {'NoncurrentDays': 1}, 'Expiration': {'ExpiredObjectDeleteMarker': True}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(7*lc_interval) + + response = client.list_object_versions(Bucket=bucket_name) + init_versions = response['Versions'] + deleted_versions = response['DeleteMarkers'] + total_expire_versions = init_versions + deleted_versions + + assert len(total_init_versions) == 4 + assert len(total_expire_versions) == 2 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_deletemarker_expiration_with_days_tag(): + bucket_name = get_new_bucket() + client = get_client() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + create_multiple_versions(client, bucket_name, "test1/a", 1) + client.delete_object(Bucket=bucket_name, Key="test1/a") + + rules=[{'ID': 'rule1', 'NoncurrentVersionExpiration': {'NoncurrentDays': 1}, 'Expiration': {'Days': 5}, 'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(2*lc_interval) + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] if ('Versions' in response) else [] + delete_markers = response['DeleteMarkers'] if ('DeleteMarkers' in response) else [] + + assert len(versions) == 0 + assert len(delete_markers) == 1 + + time.sleep(4*lc_interval) + + response = client.list_object_versions(Bucket=bucket_name) + delete_markers = response['DeleteMarkers'] if ('DeleteMarkers' in response) else [] + + assert len(delete_markers) == 0 + +@pytest.mark.lifecycle +def test_lifecycle_set_multipart(): + bucket_name = get_new_bucket() + client = get_client() + rules = [ + {'ID': 'rule1', 'Prefix': 'test1/', 'Status': 'Enabled', + 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 2}}, + {'ID': 'rule2', 'Prefix': 'test2/', 'Status': 'Disabled', + 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 3}} + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_multipart_expiration(): + bucket_name = get_new_bucket() + client = get_client() + + key_names = ['test1/a', 'test2/'] + upload_ids = [] + + for key in key_names: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + upload_ids.append(response['UploadId']) + + response = client.list_multipart_uploads(Bucket=bucket_name) + init_uploads = response['Uploads'] + + rules = [ + {'ID': 'rule1', 'Prefix': 'test1/', 'Status': 'Enabled', + 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 2}}, + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(5*lc_interval) + + response = client.list_multipart_uploads(Bucket=bucket_name) + expired_uploads = response['Uploads'] + assert len(init_uploads) == 2 + assert len(expired_uploads) == 1 + +@pytest.mark.lifecycle +def test_lifecycle_transition_set_invalid_date(): + bucket_name = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Expiration': {'Date': '2023-09-27'},'Transitions': [{'Date': '20220927','StorageClass': 'GLACIER'}],'Prefix': 'test1/', 'Status':'Enabled'}] + lifecycle = {'Rules': rules} + e = assert_raises(ClientError, client.put_bucket_lifecycle_configuration, Bucket=bucket_name, LifecycleConfiguration=lifecycle) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +def _test_encryption_sse_customer_write(file_size): + """ + Tests Create a file of A's, use it to set_contents_from_file. + Create a file of B's, use it to re-set_contents_from_file. + Re-read the contents, and confirm we get B's + """ + bucket_name = get_new_bucket() + client = get_client() + key = 'testobj' + data = 'A'*file_size + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == data + +# The test harness for lifecycle is configured to treat days as 10 second intervals. +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.fails_on_aws +def test_lifecycle_transition(): + sc = configured_storage_classes() + if len(sc) < 3: + pytest.skip('requires 3 or more storage classes') + + bucket_name = _create_objects(keys=['expire1/foo', 'expire1/bar', 'keep2/foo', + 'keep2/bar', 'expire3/foo', 'expire3/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': sc[1]}], 'Prefix': 'expire1/', 'Status': 'Enabled'}, + {'ID': 'rule2', 'Transitions': [{'Days': 6, 'StorageClass': sc[2]}], 'Prefix': 'expire3/', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Get list of all keys + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == 6 + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys['STANDARD']) == 4 + assert len(expire1_keys[sc[1]]) == 2 + assert len(expire1_keys[sc[2]]) == 0 + + # Wait for next expiration cycle + time.sleep(lc_interval) + keep2_keys = list_bucket_storage_class(client, bucket_name) + assert len(keep2_keys['STANDARD']) == 4 + assert len(keep2_keys[sc[1]]) == 2 + assert len(keep2_keys[sc[2]]) == 0 + + # Wait for final expiration cycle + time.sleep(5*lc_interval) + expire3_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire3_keys['STANDARD']) == 2 + assert len(expire3_keys[sc[1]]) == 2 + assert len(expire3_keys[sc[2]]) == 2 + +# The test harness for lifecycle is configured to treat days as 10 second intervals. +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.fails_on_aws +def test_lifecycle_transition_single_rule_multi_trans(): + sc = configured_storage_classes() + if len(sc) < 3: + pytest.skip('requires 3 or more storage classes') + + bucket_name = _create_objects(keys=['expire1/foo', 'expire1/bar', 'keep2/foo', + 'keep2/bar', 'expire3/foo', 'expire3/bar']) + client = get_client() + rules=[{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': sc[1]}, {'Days': 7, 'StorageClass': sc[2]}], 'Prefix': 'expire1/', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Get list of all keys + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == 6 + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(5*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys['STANDARD']) == 4 + assert len(expire1_keys[sc[1]]) == 2 + assert len(expire1_keys[sc[2]]) == 0 + + # Wait for next expiration cycle + time.sleep(lc_interval) + keep2_keys = list_bucket_storage_class(client, bucket_name) + assert len(keep2_keys['STANDARD']) == 4 + assert len(keep2_keys[sc[1]]) == 2 + assert len(keep2_keys[sc[2]]) == 0 + + # Wait for final expiration cycle + time.sleep(6*lc_interval) + expire3_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire3_keys['STANDARD']) == 4 + assert len(expire3_keys[sc[1]]) == 0 + assert len(expire3_keys[sc[2]]) == 2 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +def test_lifecycle_set_noncurrent_transition(): + sc = configured_storage_classes() + if len(sc) < 3: + pytest.skip('requires 3 or more storage classes') + + bucket = get_new_bucket() + client = get_client() + rules = [ + { + 'ID': 'rule1', + 'Prefix': 'test1/', + 'Status': 'Enabled', + 'NoncurrentVersionTransitions': [ + { + 'NoncurrentDays': 2, + 'StorageClass': sc[1] + }, + { + 'NoncurrentDays': 4, + 'StorageClass': sc[2] + } + ], + 'NoncurrentVersionExpiration': { + 'NoncurrentDays': 6 + } + }, + {'ID': 'rule2', 'Prefix': 'test2/', 'Status': 'Disabled', 'NoncurrentVersionExpiration': {'NoncurrentDays': 3}} + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.lifecycle_transition +@pytest.mark.fails_on_aws +def test_lifecycle_noncur_transition(): + sc = configured_storage_classes() + if len(sc) < 3: + pytest.skip('requires 3 or more storage classes') + + bucket = get_new_bucket() + client = get_client() + + # before enabling versioning, create a plain entry + # which should get transitioned/expired similar to + # other non-current versioned entries. + key = 'test1/a' + content = 'fooz' + client.put_object(Bucket=bucket, Key=key, Body=content) + + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + + rules = [ + { + 'ID': 'rule1', + 'Prefix': 'test1/', + 'Status': 'Enabled', + 'NoncurrentVersionTransitions': [ + { + 'NoncurrentDays': 1, + 'StorageClass': sc[1] + }, + { + 'NoncurrentDays': 5, + 'StorageClass': sc[2] + } + ], + 'NoncurrentVersionExpiration': { + 'NoncurrentDays': 9 + } + } + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + create_multiple_versions(client, bucket, "test1/a", 2) + create_multiple_versions(client, bucket, "test1/b", 3) + + init_keys = list_bucket_storage_class(client, bucket) + assert len(init_keys['STANDARD']) == 6 + + lc_interval = get_lc_debug_interval() + + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 4 + assert len(expire1_keys[sc[2]]) == 0 + + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 0 + assert len(expire1_keys[sc[2]]) == 4 + + time.sleep(6*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 0 + assert len(expire1_keys[sc[2]]) == 0 + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.lifecycle_transition +def test_lifecycle_plain_null_version_current_transition(): + sc = configured_storage_classes() + if len(sc) < 2: + pytest.skip('requires 2 or more storage classes') + + target_sc = sc[1] + assert target_sc != 'STANDARD' + + bucket = get_new_bucket() + check_versioning(bucket, None) + + # create a plain object before enabling versioning; + # this will be transitioned as a current version + client = get_client() + key = 'testobjfoo' + content = 'fooz' + client.put_object(Bucket=bucket, Key=key, Body=content) + + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration={ + 'Rules': [ + { + 'ID': 'rule1', + 'Prefix': 'testobj', + 'Status': 'Enabled', + 'Transitions': [ + { + 'Days': 1, + 'StorageClass': target_sc + }, + ] + } + ] + }) + + lc_interval = get_lc_debug_interval() + time.sleep(4*lc_interval) + + keys = list_bucket_storage_class(client, bucket) + assert len(keys['STANDARD']) == 0 + assert len(keys[target_sc]) == 1 + +def verify_object(client, bucket, key, content=None, sc=None): + response = client.get_object(Bucket=bucket, Key=key) + + if (sc == None): + sc = 'STANDARD' + + if ('StorageClass' in response): + assert response['StorageClass'] == sc + else: #storage class should be STANDARD + assert 'STANDARD' == sc + + if (content != None): + body = _get_body(response) + assert body == content + +def verify_transition(client, bucket, key, sc=None, version=None): + if (version != None): + response = client.head_object(Bucket=bucket, Key=key, VersionId=version) + else: + response = client.head_object(Bucket=bucket, Key=key) + + # Iterate over the contents to find the StorageClass + if 'StorageClass' in response: + assert response['StorageClass'] == sc + else: # storage class should be STANDARD + assert 'STANDARD' == sc + +# The test harness for lifecycle is configured to treat days as 10 second intervals. +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_cloud_transition(): + cloud_sc = get_cloud_storage_class() + if cloud_sc == None: + pytest.skip('no cloud_storage_class configured') + + retain_head_object = get_cloud_retain_head_object() + target_path = get_cloud_target_path() + target_sc = get_cloud_target_storage_class() + + keys=['expire1/foo', 'expire1/bar', 'keep2/foo', 'keep2/bar'] + bucket_name = _create_objects(keys=keys) + client = get_client() + rules=[{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': 'expire1/', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Get list of all keys + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == 4 + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(10*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys['STANDARD']) == 2 + + if (retain_head_object != None and retain_head_object == "true"): + assert len(expire1_keys[cloud_sc]) == 2 + else: + assert len(expire1_keys[cloud_sc]) == 0 + + time.sleep(2*lc_interval) + # Check if objects copied to target path + if target_path == None: + target_path = "rgwx-default-" + cloud_sc.lower() + "-cloud-bucket" + prefix = bucket_name + "/" + + cloud_client = get_cloud_client() + + time.sleep(12*lc_interval) + expire1_key1_str = prefix + keys[0] + verify_object(cloud_client, target_path, expire1_key1_str, keys[0], target_sc) + + expire1_key2_str = prefix + keys[1] + verify_object(cloud_client, target_path, expire1_key2_str, keys[1], target_sc) + + # Now verify the object on source rgw + src_key = keys[0] + if (retain_head_object != None and retain_head_object == "true"): + # verify HEAD response + response = client.head_object(Bucket=bucket_name, Key=keys[0]) + assert 0 == response['ContentLength'] + assert cloud_sc == response['StorageClass'] + + allow_readthrough = get_allow_read_through() + if (allow_readthrough == None or allow_readthrough == "false"): + # GET should return InvalidObjectState error + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=src_key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'InvalidObjectState' + + # COPY of object should return InvalidObjectState error + copy_source = {'Bucket': bucket_name, 'Key': src_key} + e = assert_raises(ClientError, client.copy, CopySource=copy_source, Bucket=bucket_name, Key='copy_obj') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'InvalidObjectState' + + # DELETE should succeed + response = client.delete_object(Bucket=bucket_name, Key=src_key) + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=src_key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +# Similar to 'test_lifecycle_transition' but for cloud transition +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_cloud_multiple_transition(): + cloud_sc = get_cloud_storage_class() + if cloud_sc == None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + retain_head_object = get_cloud_retain_head_object() + target_path = get_cloud_target_path() + target_sc = get_cloud_target_storage_class() + + sc1 = get_cloud_regular_storage_class() + + if (sc1 == None): + pytest.skip('[s3 cloud] section missing storage_class') + + sc = ['STANDARD', sc1, cloud_sc] + + keys=['expire1/foo', 'expire1/bar', 'keep2/foo', 'keep2/bar'] + bucket_name = _create_objects(keys=keys) + client = get_client() + rules=[{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': sc1}], 'Prefix': 'expire1/', 'Status': 'Enabled'}, + {'ID': 'rule2', 'Transitions': [{'Days': 5, 'StorageClass': cloud_sc}], 'Prefix': 'expire1/', 'Status': 'Enabled'}, + {'ID': 'rule3', 'Expiration': {'Days': 9}, 'Prefix': 'expire1/', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Get list of all keys + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == 4 + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 2 + assert len(expire1_keys[sc[2]]) == 0 + + # Wait for next expiration cycle + time.sleep(7*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 0 + + if (retain_head_object != None and retain_head_object == "true"): + assert len(expire1_keys[sc[2]]) == 2 + else: + assert len(expire1_keys[sc[2]]) == 0 + + # Wait for final expiration cycle + time.sleep(12*lc_interval) + expire3_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire3_keys['STANDARD']) == 2 + assert len(expire3_keys[sc[1]]) == 0 + assert len(expire3_keys[sc[2]]) == 0 + +# Noncurrent objects for cloud transition +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_noncur_cloud_transition(): + cloud_sc = get_cloud_storage_class() + if cloud_sc == None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + retain_head_object = get_cloud_retain_head_object() + target_path = get_cloud_target_path() + target_sc = get_cloud_target_storage_class() + + sc1 = get_cloud_regular_storage_class() + if (sc1 == None): + pytest.skip('[s3 cloud] section missing storage_class') + + sc = ['STANDARD', sc1, cloud_sc] + + bucket = get_new_bucket() + client = get_client() + + # before enabling versioning, create a plain entry + # which should get transitioned/expired similar to + # other non-current versioned entries. + key = 'test1/a' + content = 'fooz' + client.put_object(Bucket=bucket, Key=key, Body=content) + + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + + rules = [ + { + 'ID': 'rule1', + 'Prefix': 'test1/', + 'Status': 'Enabled', + 'NoncurrentVersionTransitions': [ + { + 'NoncurrentDays': 1, + 'StorageClass': sc[1] + }, + { + 'NoncurrentDays': 5, + 'StorageClass': sc[2] + } + ], + } + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + keys = ['test1/a', 'test1/b'] + + create_multiple_versions(client, bucket, "test1/a", 2) + create_multiple_versions(client, bucket, "test1/b", 3) + + init_keys = list_bucket_storage_class(client, bucket) + assert len(init_keys['STANDARD']) == 6 + + response = client.list_object_versions(Bucket=bucket) + + lc_interval = get_lc_debug_interval() + + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 4 + assert len(expire1_keys[sc[2]]) == 0 + + time.sleep(15*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 2 + assert len(expire1_keys[sc[1]]) == 0 + + if (retain_head_object == None or retain_head_object == "false"): + assert len(expire1_keys[sc[2]]) == 0 + else: + assert len(expire1_keys[sc[2]]) == 4 + + #check if versioned object exists on cloud endpoint + if target_path == None: + target_path = "rgwx-default-" + cloud_sc.lower() + "-cloud-bucket" + prefix = bucket + "/" + + cloud_client = get_cloud_client() + + time.sleep(lc_interval) + result = list_bucket_versions(client, bucket) + + for src_key in keys: + for k in result[src_key]: + expire1_key1_str = prefix + 'test1/a' + "-" + k['VersionId'] + verify_object(cloud_client, target_path, expire1_key1_str, None, target_sc) + +# The test harness for lifecycle is configured to treat days as 10 second intervals. +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_cloud_transition_large_obj(): + cloud_sc = get_cloud_storage_class() + if cloud_sc == None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + retain_head_object = get_cloud_retain_head_object() + target_path = get_cloud_target_path() + target_sc = get_cloud_target_storage_class() + + bucket = get_new_bucket() + client = get_client() + rules=[{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': 'expire1/', 'Status': 'Enabled'}] + + keys = ['keep/multi', 'expire1/multi'] + size = 9*1024*1024 + data = 'A'*size + + for k in keys: + client.put_object(Bucket=bucket, Body=data, Key=k) + verify_object(client, bucket, k, data) + + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + + # Wait for first expiration (plus fudge to handle the timer window) + time.sleep(12*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 1 + + + if (retain_head_object != None and retain_head_object == "true"): + assert len(expire1_keys[cloud_sc]) == 1 + else: + assert len(expire1_keys[cloud_sc]) == 0 + + # Check if objects copied to target path + if target_path == None: + target_path = "rgwx-default-" + cloud_sc.lower() + "-cloud-bucket" + prefix = bucket + "/" + + # multipart upload takes time + time.sleep(12*lc_interval) + cloud_client = get_cloud_client() + + expire1_key1_str = prefix + keys[1] + verify_object(cloud_client, target_path, expire1_key1_str, data, target_sc) + +# Test for per-bucket cloud transition targeting (target_by_bucket=true) +# When target_by_bucket is enabled: +# 1. Each source bucket transitions to a dedicated target bucket +# 2. Object keys are stored without the source bucket name prefix +# 3. Target bucket names follow template: rgwx-${zonegroup}-${storage_class}-${bucket} +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.cloud_restore +@pytest.mark.target_by_bucket +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_cloud_transition_target_by_bucket(): + """ + Test cloud transition with target_by_bucket=true. + + Validates that when target_by_bucket is enabled: + 1. Objects land in a bucket-specific target (not the shared target_path) + 2. Object keys do NOT include the source bucket name as a prefix + 3. Restore can locate and restore objects correctly + """ + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + target_by_bucket = get_cloud_target_by_bucket() + if not target_by_bucket: + pytest.skip('[s3 cloud] target_by_bucket not enabled') + + retain_head_object = get_cloud_retain_head_object() + target_sc = get_cloud_target_storage_class() + target_by_bucket_prefix = get_cloud_target_by_bucket_prefix() + + client = get_client() + cloud_client = get_cloud_client() + lc_interval = get_lc_debug_interval() + restore_period = get_restore_processor_period() + + # Create source bucket with test objects + bucket_name = get_new_bucket() + keys = ['file1.txt', 'subdir/file2.txt'] + + for key in keys: + client.put_object(Bucket=bucket_name, Key=key, Body=key) + + # Configure lifecycle rule for cloud transition + rules = [{'ID': 'rule1', + 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], + 'Prefix': '', + 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Verify initial state + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == len(keys) + + # Wait for transition to complete + time.sleep(15 * lc_interval) + + # Verify objects have transitioned in source bucket + expire_keys = list_bucket_storage_class(client, bucket_name) + if retain_head_object and retain_head_object.lower() == "true": + assert len(expire_keys.get(cloud_sc, [])) == len(keys), \ + f"Expected {len(keys)} objects in {cloud_sc}, got {len(expire_keys.get(cloud_sc, []))}" + + # Derive expected target bucket name + # Default template: rgwx-${zonegroup}-${storage_class}-${bucket} + if target_by_bucket_prefix: + expected_target = target_by_bucket_prefix.replace('${zonegroup}', 'default') + expected_target = expected_target.replace('${storage_class}', cloud_sc.lower()) + expected_target = expected_target.replace('${bucket}', bucket_name) + else: + expected_target = f"rgwx-default-{cloud_sc.lower()}-{bucket_name}" + + # Allow time for cloud operations to complete + time.sleep(5 * lc_interval) + + # Verify objects in target bucket + # With target_by_bucket=true, keys should NOT have bucket_name prefix + for key in keys: + verify_object(cloud_client, expected_target, key, key, target_sc) + + # Verify the old format (with bucket prefix) is NOT used + old_format_key = bucket_name + "/" + key + try: + cloud_client.head_object(Bucket=expected_target, Key=old_format_key) + assert False, f"Found old format key '{old_format_key}' - target_by_bucket not working" + except ClientError as e: + assert e.response['Error']['Code'] in ('404', 'NoSuchKey'), \ + f"Unexpected error: {e}" + + # Test restore functionality + restore_key = keys[0] + + # Verify object is transitioned before attempting restore + verify_transition(client, bucket_name, restore_key, cloud_sc) + + # Delete lifecycle to prevent re-transition after restore + client.delete_bucket_lifecycle(Bucket=bucket_name) + + # Restore object temporarily + client.restore_object(Bucket=bucket_name, Key=restore_key, RestoreRequest={'Days': 2}) + time.sleep(3 * restore_period) + + # Verify object is restored temporarily (storage class stays cloud_sc, but content is accessible) + verify_transition(client, bucket_name, restore_key, cloud_sc) + response = client.head_object(Bucket=bucket_name, Key=restore_key) + assert response['ContentLength'] == len(restore_key) + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.cloud_transition +@pytest.mark.cloud_restore +@pytest.mark.target_by_bucket +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_lifecycle_cloud_transition_target_by_bucket_multiple_buckets(): + """ + Test that target_by_bucket properly isolates objects between buckets. + Also tests restore functionality for one of the buckets. + """ + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + target_by_bucket = get_cloud_target_by_bucket() + if not target_by_bucket: + pytest.skip('[s3 cloud] target_by_bucket not enabled') + + target_sc = get_cloud_target_storage_class() + target_by_bucket_prefix = get_cloud_target_by_bucket_prefix() + + client = get_client() + cloud_client = get_cloud_client() + lc_interval = get_lc_debug_interval() + restore_period = get_restore_processor_period() + + # Create two source buckets + bucket_a = get_new_bucket() + bucket_b = get_new_bucket() + + key_a = 'only-in-a.txt' + key_b = 'only-in-b.txt' + client.put_object(Bucket=bucket_a, Key=key_a, Body='content-a') + client.put_object(Bucket=bucket_b, Key=key_b, Body='content-b') + + # Configure lifecycle for both buckets + rules = [{'ID': 'rule1', + 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], + 'Prefix': '', + 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_a, LifecycleConfiguration=lifecycle) + client.put_bucket_lifecycle_configuration(Bucket=bucket_b, LifecycleConfiguration=lifecycle) + + # Wait for transitions + time.sleep(20 * lc_interval) + + # Derive expected target bucket names + if target_by_bucket_prefix: + expected_target_a = target_by_bucket_prefix.replace('${zonegroup}', 'default') + expected_target_a = expected_target_a.replace('${storage_class}', cloud_sc.lower()) + expected_target_a = expected_target_a.replace('${bucket}', bucket_a) + expected_target_b = target_by_bucket_prefix.replace('${zonegroup}', 'default') + expected_target_b = expected_target_b.replace('${storage_class}', cloud_sc.lower()) + expected_target_b = expected_target_b.replace('${bucket}', bucket_b) + else: + expected_target_a = f"rgwx-default-{cloud_sc.lower()}-{bucket_a}" + expected_target_b = f"rgwx-default-{cloud_sc.lower()}-{bucket_b}" + + # Verify isolation: target_a should have key_a, NOT key_b + verify_object(cloud_client, expected_target_a, key_a, 'content-a', target_sc) + try: + cloud_client.head_object(Bucket=expected_target_a, Key=key_b) + assert False, f"Isolation violation: '{key_b}' found in target_a" + except ClientError as e: + assert e.response['Error']['Code'] in ('404', 'NoSuchKey'), \ + f"Unexpected error: {e}" + + # Verify isolation: target_b should have key_b, NOT key_a + verify_object(cloud_client, expected_target_b, key_b, 'content-b', target_sc) + try: + cloud_client.head_object(Bucket=expected_target_b, Key=key_a) + assert False, f"Isolation violation: '{key_a}' found in target_b" + except ClientError as e: + assert e.response['Error']['Code'] in ('404', 'NoSuchKey'), \ + f"Unexpected error: {e}" + + # Test restore functionality on bucket_a + # Verify object is transitioned before attempting restore + verify_transition(client, bucket_a, key_a, cloud_sc) + + # Delete lifecycle to prevent re-transition after restore + client.delete_bucket_lifecycle(Bucket=bucket_a) + + # Restore object temporarily + client.restore_object(Bucket=bucket_a, Key=key_a, RestoreRequest={'Days': 2}) + time.sleep(3 * restore_period) + + # Verify object is restored temporarily (storage class stays cloud_sc, but content is accessible) + verify_transition(client, bucket_a, key_a, cloud_sc) + response = client.head_object(Bucket=bucket_a, Key=key_a) + assert response['ContentLength'] == len('content-a') + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_restore_object_temporary(): + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + bucket = get_new_bucket() + client = get_client() + key = 'test_restore_temp' + data = 'temporary restore data' + + # Put object + client.put_object(Bucket=bucket, Key=key, Body=data) + verify_object(client, bucket, key, data) + + # Transition object to cloud storage class + rules = [{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': '', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + restore_interval = get_restore_debug_interval() + restore_period = get_restore_processor_period() + time.sleep(10 * lc_interval) + + # Verify object is transitioned + verify_transition(client, bucket, key, cloud_sc) + + # delete lifecycle to prevent re-transition before restore check + response = client.delete_bucket_lifecycle(Bucket=bucket) + + # Restore object temporarily + client.restore_object(Bucket=bucket, Key=key, RestoreRequest={'Days': 20}) + time.sleep(3*restore_period) + + # Verify object is restored temporarily + verify_transition(client, bucket, key, cloud_sc) + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentLength'] == len(data) + + # Now re-issue the request with 'Days' set to lower value + client.restore_object(Bucket=bucket, Key=key, RestoreRequest={'Days': 2}) + time.sleep(2*restore_period) + # ensure the object is still restored + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentLength'] == len(data) + + # now verify if the object is expired as per the updated days value + time.sleep(2 * (restore_interval + lc_interval)) + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentLength'] == 0 + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_restore_object_permanent(): + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + bucket = get_new_bucket() + client = get_client() + key = 'test_restore_perm' + data = 'permanent restore data' + + # Put object + client.put_object(Bucket=bucket, Key=key, Body=data) + verify_object(client, bucket, key, data) + + # Transition object to cloud storage class + rules = [{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': '', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + time.sleep(10 * lc_interval) + + # Verify object is transitioned + verify_transition(client, bucket, key, cloud_sc) + + # delete lifecycle to prevent re-transition post permanent restore + response = client.delete_bucket_lifecycle(Bucket=bucket) + + restore_period = get_restore_processor_period() + # Restore object permanently + client.restore_object(Bucket=bucket, Key=key, RestoreRequest={}) + time.sleep(3*restore_period) + + # Verify object is restored permanently + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentLength'] == len(data) + verify_transition(client, bucket, key, 'STANDARD') + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_read_through(): + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + bucket = get_new_bucket() + client_config = botocore.config.Config(connect_timeout=100, read_timeout=100) + client = get_client(client_config=client_config) + + key = 'test_restore_readthrough' + data = 'restore data with readthrough' + + # Put object + client.put_object(Bucket=bucket, Key=key, Body=data) + verify_object(client, bucket, key, data) + + # Transition object to cloud storage class + rules = [{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': '', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + restore_interval = get_restore_debug_interval() + restore_period = get_restore_processor_period() + time.sleep(10 * lc_interval) + + # Check the storage class after transitioning + verify_transition(client, bucket, key, cloud_sc) + + # delete lifecycle to prevent re-transition before restore check + response = client.delete_bucket_lifecycle(Bucket=bucket) + + # Restore the object using read_through request + allow_readthrough = get_allow_read_through() + read_through_days = get_read_through_days() + restore_period = get_restore_processor_period() + + if (allow_readthrough != None and allow_readthrough == "true"): + try: + response = client.get_object(Bucket=bucket, Key=key) + except ClientError as e: + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + + time.sleep(2 * restore_period) + response = client.head_object(Bucket=bucket, Key=key) + + assert response['ContentLength'] == len(data) + time.sleep(2 * read_through_days * (restore_interval + lc_interval)) + # verify object expired + response = client.head_object(Bucket=bucket, Key=key) + assert response['ContentLength'] == 0 + else: + e = assert_raises(ClientError, client.get_object, Bucket=bucket, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'InvalidObjectState' + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_restore_noncur_obj(): + cloud_sc = get_cloud_storage_class() + if cloud_sc == None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + retain_head_object = get_cloud_retain_head_object() + target_path = get_cloud_target_path() + target_sc = get_cloud_target_storage_class() + + sc = ['STANDARD', cloud_sc] + + bucket = get_new_bucket() + client = get_client() + + # before enabling versioning, create a plain entry + # which should get transitioned/expired similar to + # other non-current versioned entries. + key = 'test1/a' + content = 'fooz' + client.put_object(Bucket=bucket, Key=key, Body=content) + + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + + rules = [ + { + 'ID': 'rule1', + 'Prefix': 'test1/', + 'Status': 'Enabled', + 'NoncurrentVersionTransitions': [ + { + 'NoncurrentDays': 2, + 'StorageClass': cloud_sc + } + ], + } + ] + lifecycle = {'Rules': rules} + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + keys = ['test1/a'] + + (version_ids, contents) = create_multiple_versions(client, bucket, "test1/a", 2) + + contents.append(content) + + init_keys = list_bucket_storage_class(client, bucket) + print(init_keys) + assert len(init_keys['STANDARD']) == 3 + + version_ids = [] + response = client.list_object_versions(Bucket=bucket) + for version in response['Versions']: + version_ids.append(version['VersionId']) + + lc_interval = get_lc_debug_interval() + + time.sleep(7*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket) + assert len(expire1_keys['STANDARD']) == 1 + assert len(expire1_keys[cloud_sc]) == 2 + + restore_interval = get_restore_debug_interval() + restore_period = get_restore_processor_period() + + for num in range(1, 2): + verify_transition(client, bucket, key, cloud_sc, version_ids[num]) + # Restore object temporarily + client.restore_object(Bucket=bucket, Key=key, VersionId=version_ids[num], RestoreRequest={'Days': 2}) + time.sleep(2*restore_period) + + # Verify object is restored temporarily + response = client.head_object(Bucket=bucket, Key=key, VersionId=version_ids[num]) + assert response['ContentLength'] == len(contents[num]) + response = client.list_object_versions(Bucket=bucket) + versions = response['Versions'] + assert versions[1]['IsLatest'] == False + + time.sleep(2 * (restore_interval + lc_interval)) + + #verify object expired + for num in range(1, 2): + response = client.head_object(Bucket=bucket, Key=key, VersionId=version_ids[num]) + assert response['ContentLength'] == 0 + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_list_objects_restore_status(): + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + bucket = get_new_bucket() + client = get_client() + temp_key = 'test_restore_status_temp' + perm_key = 'test_restore_status_perm' + data = 'restore status listing data' + + client.put_object(Bucket=bucket, Key=temp_key, Body=data) + client.put_object(Bucket=bucket, Key=perm_key, Body=data) + + response = client.list_objects_v2( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + objs = response['Contents'] + assert len(objs) == 2 + for o in objs: + assert 'RestoreStatus' not in o + + response = client.list_objects( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + objs = response['Contents'] + assert len(objs) == 2 + for o in objs: + assert 'RestoreStatus' not in o + + rules = [{'ID': 'rule1', 'Transitions': [{'Days': 1, 'StorageClass': cloud_sc}], 'Prefix': '', 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + restore_period = get_restore_processor_period() + time.sleep(10 * lc_interval) + + verify_transition(client, bucket, temp_key, cloud_sc) + verify_transition(client, bucket, perm_key, cloud_sc) + + # delete lifecycle to prevent re-transition before restore check + client.delete_bucket_lifecycle(Bucket=bucket) + + client.restore_object(Bucket=bucket, Key=temp_key, RestoreRequest={'Days': 20}) + # permanent restore omits 'Days' + client.restore_object(Bucket=bucket, Key=perm_key, RestoreRequest={}) + time.sleep(3 * restore_period) + + response = client.list_objects_v2( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + objs = {o['Key']: o for o in response['Contents']} + assert len(objs) == 2 + assert 'RestoreStatus' in objs[temp_key] + assert objs[temp_key]['RestoreStatus']['IsRestoreInProgress'] == False + assert 'RestoreExpiryDate' in objs[temp_key]['RestoreStatus'] + assert 'RestoreStatus' in objs[perm_key] + assert objs[perm_key]['RestoreStatus']['IsRestoreInProgress'] == False + assert 'RestoreExpiryDate' not in objs[perm_key]['RestoreStatus'] + + response = client.list_objects( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + objs = {o['Key']: o for o in response['Contents']} + assert len(objs) == 2 + assert 'RestoreStatus' in objs[temp_key] + assert objs[temp_key]['RestoreStatus']['IsRestoreInProgress'] == False + assert 'RestoreExpiryDate' in objs[temp_key]['RestoreStatus'] + assert 'RestoreStatus' in objs[perm_key] + assert objs[perm_key]['RestoreStatus']['IsRestoreInProgress'] == False + assert 'RestoreExpiryDate' not in objs[perm_key]['RestoreStatus'] + + # without the header, RestoreStatus should not appear + response = client.list_objects_v2(Bucket=bucket) + objs = response['Contents'] + assert len(objs) == 2 + for o in objs: + assert 'RestoreStatus' not in o + +@pytest.mark.cloud_restore +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_list_object_versions_restore_status(): + cloud_sc = get_cloud_storage_class() + if cloud_sc is None: + pytest.skip('[s3 cloud] section missing cloud_storage_class') + + bucket = get_new_bucket() + client = get_client() + + key = 'test1/a' + data = 'restore status versioned data' + + # create initial version before enabling versioning + client.put_object(Bucket=bucket, Key=key, Body=data) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + + # create a new version so the original becomes non-current + (version_ids, contents) = create_multiple_versions(client, bucket, key, 1) + + response = client.list_object_versions( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + for v in response['Versions']: + assert 'RestoreStatus' not in v + + # transition non-current versions to cloud + rules = [{ + 'ID': 'rule1', + 'Prefix': 'test1/', + 'Status': 'Enabled', + 'NoncurrentVersionTransitions': [{ + 'NoncurrentDays': 2, + 'StorageClass': cloud_sc + }], + }] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + + lc_interval = get_lc_debug_interval() + restore_period = get_restore_processor_period() + time.sleep(7 * lc_interval) + + # find the non-current version + response = client.list_object_versions(Bucket=bucket) + noncur_id = None + for v in response['Versions']: + if not v['IsLatest']: + noncur_id = v['VersionId'] + break + assert noncur_id is not None + + verify_transition(client, bucket, key, cloud_sc, noncur_id) + + # delete lifecycle to prevent re-transition before restore check + client.delete_bucket_lifecycle(Bucket=bucket) + + client.restore_object(Bucket=bucket, Key=key, VersionId=noncur_id, RestoreRequest={'Days': 20}) + time.sleep(3 * restore_period) + + response = client.list_object_versions( + Bucket=bucket, + OptionalObjectAttributes=['RestoreStatus']) + for v in response['Versions']: + if v['VersionId'] == noncur_id: + assert 'RestoreStatus' in v + assert v['RestoreStatus']['IsRestoreInProgress'] == False + assert 'RestoreExpiryDate' in v['RestoreStatus'] + else: + assert 'RestoreStatus' not in v + + response = client.list_object_versions(Bucket=bucket) + for v in response['Versions']: + assert 'RestoreStatus' not in v + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encrypted_transfer_1b(): + _test_encryption_sse_customer_write(1) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encrypted_transfer_1kb(): + _test_encryption_sse_customer_write(1024) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encrypted_transfer_1MB(): + _test_encryption_sse_customer_write(1024*1024) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encrypted_transfer_13b(): + _test_encryption_sse_customer_write(13) + + +@pytest.mark.encryption +def test_encryption_sse_c_method_head(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*1000 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + e = assert_raises(ClientError, client.head_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.HeadObject', lf) + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.encryption +def test_encryption_sse_c_present(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*1000 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +def test_encryption_sse_c_other_key(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*100 + key = 'testobj' + sse_client_headers_A = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + sse_client_headers_B = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': '6b+WOZ1T3cqZMxgThRcXAQBrS5mXKdDUphvpxptl9/4=', + 'x-amz-server-side-encryption-customer-key-md5': 'arxBvwY2V4SiOne6yppVPQ==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers_A)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers_B)) + client.meta.events.register('before-call.s3.GetObject', lf) + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +def test_encryption_sse_c_invalid_md5(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*100 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'AAAAAAAAAAAAAAAAAAAAAA==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +def test_encryption_sse_c_no_md5(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*100 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + +@pytest.mark.encryption +def test_encryption_sse_c_no_key(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*100 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + +@pytest.mark.encryption +def test_encryption_key_no_sse_c(): + bucket_name = get_new_bucket() + client = get_client() + data = 'A'*100 + key = 'testobj' + sse_client_headers = { + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +def _multipart_upload_enc(client, bucket_name, key, size, part_size, init_headers, part_headers, metadata, resend_parts): + """ + generate a multi-part upload for a random file of specifed size, + if requested, generate a list of the parts + return the upload descriptor + """ + if client == None: + client = get_client() + + lf = (lambda **kwargs: kwargs['params']['headers'].update(init_headers)) + client.meta.events.register('before-call.s3.CreateMultipartUpload', lf) + if metadata == None: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key) + else: + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, Metadata=metadata) + + upload_id = response['UploadId'] + s = '' + parts = [] + for i, part in enumerate(generate_random(size, part_size)): + # part_num is necessary because PartNumber for upload_part and in parts must start at 1 and i starts at 0 + part_num = i+1 + s += part + lf = (lambda **kwargs: kwargs['params']['headers'].update(part_headers)) + client.meta.events.register('before-call.s3.UploadPart', lf) + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part) + parts.append({'ETag': response['ETag'].strip('"'), 'PartNumber': part_num}) + if i in resend_parts: + lf = (lambda **kwargs: kwargs['params']['headers'].update(part_headers)) + client.meta.events.register('before-call.s3.UploadPart', lf) + client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=part_num, Body=part) + + return (upload_id, s, parts) + +def _check_content_using_range_enc(client, bucket_name, key, data, size, step, enc_headers=None): + for ofs in range(0, size, step): + toread = size - ofs + if toread > step: + toread = step + end = ofs + toread - 1 + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + r = 'bytes={s}-{e}'.format(s=ofs, e=end) + response = client.get_object(Bucket=bucket_name, Key=key, Range=r) + read_range = response['ContentLength'] + body = _get_body(response) + assert read_range == toread + assert body == data[ofs:end+1] + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encryption_sse_c_multipart_upload(): + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + partlen = 5*1024*1024 + metadata = {'foo': 'bar'} + enc_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + 'Content-Type': content_type + } + resend_parts = [] + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, objlen, + part_size=partlen, init_headers=enc_headers, part_headers=enc_headers, metadata=metadata, resend_parts=resend_parts) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.CompleteMultipartUpload', lf) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + response = client.get_object(Bucket=bucket_name, Key=key) + + assert response['Metadata'] == metadata + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == content_type + + body = _get_body(response) + assert body == data + size = response['ContentLength'] + assert len(body) == size + + _check_content_using_range_enc(client, bucket_name, key, data, size, 1000000, enc_headers=enc_headers) + _check_content_using_range_enc(client, bucket_name, key, data, size, 10000000, enc_headers=enc_headers) + for i in range(-1,2): + _check_content_using_range_enc(client, bucket_name, key, data, size, partlen + i, enc_headers=enc_headers) + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encryption_sse_c_unaligned_multipart_upload(): + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + partlen = 1 + 5 * 1024 * 1024 # not a multiple of the 4k encryption block size + metadata = {'foo': 'bar'} + enc_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + 'Content-Type': content_type + } + resend_parts = [] + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, objlen, + part_size=partlen, init_headers=enc_headers, part_headers=enc_headers, metadata=metadata, resend_parts=resend_parts) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.CompleteMultipartUpload', lf) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + response = client.get_object(Bucket=bucket_name, Key=key) + + assert response['Metadata'] == metadata + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == content_type + + body = _get_body(response) + assert body == data + size = response['ContentLength'] + assert len(body) == size + + _check_content_using_range_enc(client, bucket_name, key, data, size, 1000000, enc_headers=enc_headers) + _check_content_using_range_enc(client, bucket_name, key, data, size, 10000000, enc_headers=enc_headers) + for i in range(-1,2): + _check_content_using_range_enc(client, bucket_name, key, data, size, partlen + i, enc_headers=enc_headers) + +@pytest.mark.encryption +# TODO: remove this fails_on_rgw when I fix it +@pytest.mark.fails_on_rgw +def test_encryption_sse_c_multipart_invalid_chunks_1(): + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + init_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + 'Content-Type': content_type + } + part_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': '6b+WOZ1T3cqZMxgThRcXAQBrS5mXKdDUphvpxptl9/4=', + 'x-amz-server-side-encryption-customer-key-md5': 'arxBvwY2V4SiOne6yppVPQ==' + } + resend_parts = [] + + e = assert_raises(ClientError, _multipart_upload_enc, client=client, bucket_name=bucket_name, + key=key, size=objlen, part_size=5*1024*1024, init_headers=init_headers, part_headers=part_headers, metadata=metadata, resend_parts=resend_parts) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +# TODO: remove this fails_on_rgw when I fix it +@pytest.mark.fails_on_rgw +def test_encryption_sse_c_multipart_invalid_chunks_2(): + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + init_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + 'Content-Type': content_type + } + part_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'AAAAAAAAAAAAAAAAAAAAAA==' + } + resend_parts = [] + + e = assert_raises(ClientError, _multipart_upload_enc, client=client, bucket_name=bucket_name, + key=key, size=objlen, part_size=5*1024*1024, init_headers=init_headers, part_headers=part_headers, metadata=metadata, resend_parts=resend_parts) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +def test_encryption_sse_c_multipart_bad_download(): + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + put_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==', + 'Content-Type': content_type + } + get_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': '6b+WOZ1T3cqZMxgThRcXAQBrS5mXKdDUphvpxptl9/4=', + 'x-amz-server-side-encryption-customer-key-md5': 'arxBvwY2V4SiOne6yppVPQ==' + } + resend_parts = [] + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, objlen, + part_size=5*1024*1024, init_headers=put_headers, part_headers=put_headers, metadata=metadata, resend_parts=resend_parts) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(put_headers)) + client.meta.events.register('before-call.s3.CompleteMultipartUpload', lf) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + lf = (lambda **kwargs: kwargs['params']['headers'].update(put_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + response = client.get_object(Bucket=bucket_name, Key=key) + + assert response['Metadata'] == metadata + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == content_type + + lf = (lambda **kwargs: kwargs['params']['headers'].update(get_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encryption_sse_c_post_object_authenticated_request(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["starts-with", "$x-amz-server-side-encryption-customer-algorithm", ""], \ + ["starts-with", "$x-amz-server-side-encryption-customer-key", ""], \ + ["starts-with", "$x-amz-server-side-encryption-customer-key-md5", ""], \ + ["content-length-range", 0, 1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"), + ('x-amz-server-side-encryption-customer-algorithm', 'AES256'), \ + ('x-amz-server-side-encryption-customer-key', 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs='), \ + ('x-amz-server-side-encryption-customer-key-md5', 'DWygnHRtgiJ77HCm+1rvHw=='), \ + ('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + + get_headers = { + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + lf = (lambda **kwargs: kwargs['params']['headers'].update(get_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encryption_sse_c_enforced_with_bucket_policy(): + bucket_name = get_new_bucket() + client = get_client() + + deny_unencrypted_obj = { + "Null" : { + "s3:x-amz-server-side-encryption-customer-algorithm": "true" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s = Statement("s3:PutObject", resource, effect="Deny", condition=deny_unencrypted_obj) + policy_document = p.add_statement(s).to_json() + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + check_access_denied(client.put_object, Bucket=bucket_name, Key='foo', Body='bar') + + client.put_object( + Bucket=bucket_name, Key='foo', Body='bar', + SSECustomerAlgorithm='AES256', + SSECustomerKey='pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + SSECustomerKeyMD5='DWygnHRtgiJ77HCm+1rvHw==' + ) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_encryption_sse_c_deny_algo_with_bucket_policy(): + bucket_name = get_new_bucket() + client = get_client() + + deny_incorrect_algo = { + "StringNotEquals": { + "s3:x-amz-server-side-encryption-customer-algorithm": "AES256" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s = Statement("s3:PutObject", resource, effect="Deny", condition=deny_incorrect_algo) + policy_document = p.add_statement(s).to_json() + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + check_access_denied(client.put_object, Bucket=bucket_name, Key='foo', SSECustomerAlgorithm='AES192') + + client.put_object( + Bucket=bucket_name, Key='foo', Body='bar', + SSECustomerAlgorithm='AES256', + SSECustomerKey='pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + SSECustomerKeyMD5='DWygnHRtgiJ77HCm+1rvHw==' + ) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def _test_sse_kms_customer_write(file_size, key_id = 'testkey-1'): + """ + Tests Create a file of A's, use it to set_contents_from_file. + Create a file of B's, use it to re-set_contents_from_file. + Re-read the contents, and confirm we get B's + """ + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': key_id + } + data = 'A'*file_size + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key='testobj', Body=data) + + response = client.get_object(Bucket=bucket_name, Key='testobj') + body = _get_body(response) + assert body == data + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_method_head(): + kms_keyid = get_main_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid + } + data = 'A'*1000 + key = 'testobj' + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == kms_keyid + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.HeadObject', lf) + e = assert_raises(ClientError, client.head_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_present(): + kms_keyid = get_main_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid + } + data = 'A'*100 + key = 'testobj' + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == data + +@pytest.mark.encryption +def test_sse_kms_no_key(): + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + } + data = 'A'*100 + key = 'testobj' + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + + +@pytest.mark.encryption +def test_sse_kms_not_declared(): + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption-aws-kms-key-id': 'testkey-2' + } + data = 'A'*100 + key = 'testobj' + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key, Body=data) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_multipart_upload(): + kms_keyid = get_main_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + enc_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid, + 'Content-Type': content_type + } + resend_parts = [] + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, objlen, + part_size=5*1024*1024, init_headers=enc_headers, part_headers=enc_headers, metadata=metadata, resend_parts=resend_parts) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.CompleteMultipartUpload', lf) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + lf = (lambda **kwargs: kwargs['params']['headers'].update(part_headers)) + client.meta.events.register('before-call.s3.UploadPart', lf) + + response = client.get_object(Bucket=bucket_name, Key=key) + + assert response['Metadata'] == metadata + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == content_type + + body = _get_body(response) + assert body == data + size = response['ContentLength'] + assert len(body) == size + + _check_content_using_range(key, bucket_name, data, 1000000) + _check_content_using_range(key, bucket_name, data, 10000000) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_multipart_invalid_chunks_1(): + kms_keyid = get_main_kms_keyid() + kms_keyid2 = get_secondary_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/bla' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + init_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid, + 'Content-Type': content_type + } + part_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid2 + } + resend_parts = [] + + _multipart_upload_enc(client, bucket_name, key, objlen, part_size=5*1024*1024, + init_headers=init_headers, part_headers=part_headers, metadata=metadata, + resend_parts=resend_parts) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_multipart_invalid_chunks_2(): + kms_keyid = get_main_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + init_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid, + 'Content-Type': content_type + } + part_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': 'testkey-not-present' + } + resend_parts = [] + + _multipart_upload_enc(client, bucket_name, key, objlen, part_size=5*1024*1024, + init_headers=init_headers, part_headers=part_headers, metadata=metadata, + resend_parts=resend_parts) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_post_object_authenticated_request(): + kms_keyid = get_main_kms_keyid() + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["starts-with", "$x-amz-server-side-encryption", ""], \ + ["starts-with", "$x-amz-server-side-encryption-aws-kms-key-id", ""], \ + ["content-length-range", 0, 1024]\ + ]\ + } + + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"), + ('x-amz-server-side-encryption', 'aws:kms'), \ + ('x-amz-server-side-encryption-aws-kms-key-id', kms_keyid), \ + ('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_transfer_1b(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + _test_sse_kms_customer_write(1, key_id = kms_keyid) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_transfer_1kb(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + _test_sse_kms_customer_write(1024, key_id = kms_keyid) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_transfer_1MB(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + _test_sse_kms_customer_write(1024*1024, key_id = kms_keyid) + + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_transfer_13b(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + _test_sse_kms_customer_write(13, key_id = kms_keyid) + + +@pytest.mark.encryption +def test_sse_kms_read_declare(): + bucket_name = get_new_bucket() + client = get_client() + sse_kms_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': 'testkey-1' + } + data = 'A'*100 + key = 'testobj' + + client.put_object(Bucket=bucket_name, Key=key, Body=data) + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_kms_client_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.bucket_policy +def test_bucket_policy(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + response = alt_client.list_objects(Bucket=bucket_name) + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +@pytest.mark.list_objects_v2 +def test_bucketv2_policy(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + response = alt_client.list_objects_v2(Bucket=bucket_name) + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_bucket_policy_deny_self_denied_policy(iam_root): + root_client = get_iam_root_client(service_name="s3") + bucket_name = get_new_bucket(root_client) + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": "*", + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucketPolicy", + "s3:DeleteBucketPolicy", + ], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + root_client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + # non-root account should not be able to get, put or delete bucket policy + root_alt_client = create_iam_user_s3client(iam_root) + check_access_denied(root_alt_client.get_bucket_policy, Bucket=bucket_name) + check_access_denied(root_alt_client.delete_bucket_policy, Bucket=bucket_name) + check_access_denied(root_alt_client.put_bucket_policy, Bucket=bucket_name, Policy=policy_document) + + # root account should be able to get, put or delete bucket policy + response = root_client.get_bucket_policy(Bucket=bucket_name) + assert response['Policy'] == policy_document + root_client.delete_bucket_policy(Bucket=bucket_name) + root_client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + +@pytest.mark.bucket_policy +@pytest.mark.iam_account +@pytest.mark.iam_user +def test_bucket_policy_deny_self_denied_policy_confirm_header(iam_root): + root_client = get_iam_root_client(service_name="s3") + bucket_name = get_new_bucket(root_client) + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": "*", + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucketPolicy", + "s3:DeleteBucketPolicy", + ], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + root_client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document, ConfirmRemoveSelfBucketAccess=True) + + # non-root account should not be able to get, put or delete bucket policy + root_alt_client = create_iam_user_s3client(iam_root) + check_access_denied(root_alt_client.get_bucket_policy, Bucket=bucket_name) + check_access_denied(root_alt_client.delete_bucket_policy, Bucket=bucket_name) + check_access_denied(root_alt_client.put_bucket_policy, Bucket=bucket_name, Policy=policy_document) + + # root account should not be able to get, put or delete bucket policy + check_access_denied(root_client.get_bucket_policy, Bucket=bucket_name) + check_access_denied(root_client.delete_bucket_policy, Bucket=bucket_name) + check_access_denied(root_client.put_bucket_policy, Bucket=bucket_name, Policy=policy_document) + +@pytest.mark.bucket_policy +def test_bucket_policy_acl(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_acl(Bucket=bucket_name, ACL='authenticated-read') + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + e = assert_raises(ClientError, alt_client.list_objects, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + client.delete_bucket_policy(Bucket=bucket_name) + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + +@pytest.mark.bucket_policy +@pytest.mark.list_objects_v2 +def test_bucketv2_policy_acl(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_acl(Bucket=bucket_name, ACL='authenticated-read') + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + e = assert_raises(ClientError, alt_client.list_objects_v2, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + client.delete_bucket_policy(Bucket=bucket_name) + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + +@pytest.mark.bucket_policy +def test_bucket_policy_different_tenant(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + # use the tenanted client to list the global tenant's bucket + tenant_client = get_tenant_client() + tenant_client.meta.events.unregister("before-parameter-build.s3", validate_bucket_name) + response = tenant_client.list_objects(Bucket=":{}".format(bucket_name)) + + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +def test_bucket_policy_multipart(): + client = get_client() + alt_client = get_alt_client() + bucket_name = get_new_bucket(client) + key = 'mpobj' + + # alt user has no permission + assert_raises(ClientError, alt_client.create_multipart_upload, Bucket=bucket_name, Key=key) + + # grant permission on bucket ARN but not objects + client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:PutObject", + "Resource": f"arn:aws:s3:::{bucket_name}" + }] + })) + assert_raises(ClientError, alt_client.create_multipart_upload, Bucket=bucket_name, Key=key) + + # grant permission on object ARN + client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:PutObject", + "Resource": f"arn:aws:s3:::{bucket_name}/{key}" + }] + })) + alt_client.create_multipart_upload(Bucket=bucket_name, Key=key) + +@pytest.mark.bucket_policy +def test_bucket_policy_tenanted_bucket(): + tenant_client = get_tenant_client() + bucket_name = get_new_bucket(tenant_client) + key = 'asdf' + tenant_client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + tenant_client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + tenant = get_tenant_name() + + # use the global tenant's client to list the tenanted bucket + client = get_client() + client.meta.events.unregister("before-parameter-build.s3", validate_bucket_name) + + response = client.list_objects(Bucket="{}:{}".format(tenant, bucket_name)) + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +def test_bucket_policy_another_bucket(): + bucket_name = get_new_bucket() + bucket_name2 = get_new_bucket() + client = get_client() + key = 'asdf' + key2 = 'abcd' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + client.put_object(Bucket=bucket_name2, Key=key2, Body='abcd') + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "arn:aws:s3:::*", + "arn:aws:s3:::*/*" + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + response = client.get_bucket_policy(Bucket=bucket_name) + response_policy = response['Policy'] + + client.put_bucket_policy(Bucket=bucket_name2, Policy=response_policy) + + alt_client = get_alt_client() + response = alt_client.list_objects(Bucket=bucket_name) + assert len(response['Contents']) == 1 + + alt_client = get_alt_client() + response = alt_client.list_objects(Bucket=bucket_name2) + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +@pytest.mark.list_objects_v2 +def test_bucketv2_policy_another_bucket(): + bucket_name = get_new_bucket() + bucket_name2 = get_new_bucket() + client = get_client() + key = 'asdf' + key2 = 'abcd' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + client.put_object(Bucket=bucket_name2, Key=key2, Body='abcd') + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "arn:aws:s3:::*", + "arn:aws:s3:::*/*" + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + response = client.get_bucket_policy(Bucket=bucket_name) + response_policy = response['Policy'] + + client.put_bucket_policy(Bucket=bucket_name2, Policy=response_policy) + + alt_client = get_alt_client() + response = alt_client.list_objects_v2(Bucket=bucket_name) + assert len(response['Contents']) == 1 + + alt_client = get_alt_client() + response = alt_client.list_objects_v2(Bucket=bucket_name2) + assert len(response['Contents']) == 1 + +@pytest.mark.bucket_policy +# TODO: remove this fails_on_rgw when I fix it +@pytest.mark.fails_on_rgw +def test_bucket_policy_set_condition_operator_end_with_IfExists(): + bucket_name = get_new_bucket() + client = get_client() + key = 'foo' + client.put_object(Bucket=bucket_name, Key=key) + policy = '''{ + "Version":"2012-10-17", + "Statement": [{ + "Sid": "Allow Public Access to All Objects", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Condition": { + "StringLikeIfExists": { + "aws:Referer": "http://www.example.com/*" + } + }, + "Resource": "arn:aws:s3:::%s/*" + } + ] + }''' % bucket_name + # boto3.set_stream_logger(name='botocore') + client.put_bucket_policy(Bucket=bucket_name, Policy=policy) + + request_headers={'referer': 'http://www.example.com/'} + + lf = (lambda **kwargs: kwargs['params']['headers'].update(request_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + + response = client.get_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + request_headers={'referer': 'http://www.example.com/index.html'} + + lf = (lambda **kwargs: kwargs['params']['headers'].update(request_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + + response = client.get_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # the 'referer' headers need to be removed for this one + #response = client.get_object(Bucket=bucket_name, Key=key) + #assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + request_headers={'referer': 'http://example.com'} + + lf = (lambda **kwargs: kwargs['params']['headers'].update(request_headers)) + client.meta.events.register('before-call.s3.GetObject', lf) + + # TODO: Compare Requests sent in Boto3, Wireshark, RGW Log for both boto and boto3 + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + response = client.get_bucket_policy(Bucket=bucket_name) + print(response) + +@pytest.mark.bucket_policy +def test_set_get_del_bucket_policy(): + bucket_name = get_new_bucket() + client = get_client() + key = 'asdf' + client.put_object(Bucket=bucket_name, Key=key, Body='asdf') + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + response = client.get_bucket_policy(Bucket=bucket_name) + response_policy = response['Policy'] + assert policy_document == response_policy + + client.delete_bucket_policy(Bucket=bucket_name) + + try: + response = client.get_bucket_policy(Bucket=bucket_name) + except botocore.exceptions.ClientError as e: + print (e) + assert e.response['Error']['Code'] == 'NoSuchBucketPolicy' + +def _create_simple_tagset(count): + tagset = [] + for i in range(count): + tagset.append({'Key': str(i), 'Value': str(i)}) + + return {'TagSet': tagset} + +def _make_random_string(size): + return ''.join(random.choice(string.ascii_letters) for _ in range(size)) + +@pytest.mark.tagging +def test_set_multipart_tagging(): + bucket_name = get_new_bucket() + client = get_client() + tags='Hello=World&foo=bar' + key = "mymultipart" + objlen = 1 + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, tagging=tags) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 2 + assert response['TagSet'][0]['Key'] == 'Hello' + assert response['TagSet'][0]['Value'] == 'World' + assert response['TagSet'][1]['Key'] == 'foo' + assert response['TagSet'][1]['Value'] == 'bar' + + response = client.delete_object_tagging(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_get_obj_tagging(): + key = 'testputtags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + input_tagset = _create_simple_tagset(2) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + + +@pytest.mark.tagging +def test_get_obj_head_tagging(): + key = 'testputtags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + count = 2 + + input_tagset = _create_simple_tagset(count) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-tagging-count'] == str(count) + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_put_max_tags(): + key = 'testputmaxtags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + input_tagset = _create_simple_tagset(10) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + +@pytest.mark.tagging +def test_put_excess_tags(): + key = 'testputmaxtags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + input_tagset = _create_simple_tagset(11) + e = assert_raises(ClientError, client.put_object_tagging, Bucket=bucket_name, Key=key, Tagging=input_tagset) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidTag' + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +@pytest.mark.tagging +def test_put_max_kvsize_tags(): + key = 'testputmaxkeysize' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + tagset = [] + for i in range(10): + k = _make_random_string(128) + v = _make_random_string(256) + tagset.append({'Key': k, 'Value': v}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + for kv_pair in response['TagSet']: + assert kv_pair in input_tagset['TagSet'] + +@pytest.mark.tagging +def test_put_excess_key_tags(): + key = 'testputexcesskeytags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + tagset = [] + for i in range(10): + k = _make_random_string(129) + v = _make_random_string(256) + tagset.append({'Key': k, 'Value': v}) + + input_tagset = {'TagSet': tagset} + + e = assert_raises(ClientError, client.put_object_tagging, Bucket=bucket_name, Key=key, Tagging=input_tagset) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidTag' + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +@pytest.mark.tagging +def test_put_excess_val_tags(): + key = 'testputexcesskeytags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + tagset = [] + for i in range(10): + k = _make_random_string(128) + v = _make_random_string(257) + tagset.append({'Key': k, 'Value': v}) + + input_tagset = {'TagSet': tagset} + + e = assert_raises(ClientError, client.put_object_tagging, Bucket=bucket_name, Key=key, Tagging=input_tagset) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidTag' + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_put_modify_tags(): + key = 'testputmodifytags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + tagset = [] + tagset.append({'Key': 'key', 'Value': 'val'}) + tagset.append({'Key': 'key2', 'Value': 'val2'}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + + tagset2 = [] + tagset2.append({'Key': 'key3', 'Value': 'val3'}) + + input_tagset2 = {'TagSet': tagset2} + + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset2) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset2['TagSet'] + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_put_delete_tags(): + key = 'testputmodifytags' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + input_tagset = _create_simple_tagset(2) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + + response = client.delete_object_tagging(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_post_object_tags_anonymous_request(): + bucket_name = get_new_bucket_name() + client = get_client() + url = _get_post_url(bucket_name) + client.create_bucket(ACL='public-read-write', Bucket=bucket_name) + + key_name = "foo.txt" + input_tagset = _create_simple_tagset(2) + # xml_input_tagset is the same as input_tagset in xml. + # There is not a simple way to change input_tagset to xml like there is in the boto2 tetss + xml_input_tagset = "0011" + + + payload = OrderedDict([ + ("key" , key_name), + ("acl" , "public-read"), + ("Content-Type" , "text/plain"), + ("tagging", xml_input_tagset), + ('file', ('bar')), + ]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key=key_name) + body = _get_body(response) + assert body == 'bar' + + response = client.get_object_tagging(Bucket=bucket_name, Key=key_name) + assert response['TagSet'] == input_tagset['TagSet'] + +@pytest.mark.tagging +def test_post_object_tags_authenticated_request(): + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [ + {"bucket": bucket_name}, + ["starts-with", "$key", "foo"], + {"acl": "private"}, + ["starts-with", "$Content-Type", "text/plain"], + ["content-length-range", 0, 1024], + ["starts-with", "$tagging", ""] + ]} + + # xml_input_tagset is the same as `input_tagset = _create_simple_tagset(2)` in xml + # There is not a simple way to change input_tagset to xml like there is in the boto2 tetss + xml_input_tagset = "0011" + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ + ("key" , "foo.txt"), + ("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("tagging", xml_input_tagset), + ("Content-Type" , "text/plain"), + ('file', ('bar'))]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + body = _get_body(response) + assert body == 'bar' + + +@pytest.mark.tagging +@pytest.mark.fails_on_dbstore +def test_put_obj_with_tags(): + bucket_name = get_new_bucket() + client = get_client() + key = 'testtagobj1' + data = 'A'*100 + + tagset = [] + tagset.append({'Key': 'bar', 'Value': ''}) + tagset.append({'Key': 'foo', 'Value': 'bar'}) + + put_obj_tag_headers = { + 'x-amz-tagging' : 'foo=bar&bar' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(put_obj_tag_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + + client.put_object(Bucket=bucket_name, Key=key, Body=data) + response = client.get_object(Bucket=bucket_name, Key=key) + body = _get_body(response) + assert body == data + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + response_tagset = response['TagSet'] + tagset = tagset + assert response_tagset == tagset + +def _make_arn_resource(path="*"): + return "arn:aws:s3:::{}".format(path) + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_get_tags_acl_public(): + key = 'testputtagsacl' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + resource = _make_arn_resource("{}/{}".format(bucket_name, key)) + policy_document = make_json_policy("s3:GetObjectTagging", + resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + input_tagset = _create_simple_tagset(10) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + + response = alt_client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_put_tags_acl_public(): + key = 'testputtagsacl' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + resource = _make_arn_resource("{}/{}".format(bucket_name, key)) + policy_document = make_json_policy("s3:PutObjectTagging", + resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + input_tagset = _create_simple_tagset(10) + alt_client = get_alt_client() + response = alt_client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert response['TagSet'] == input_tagset['TagSet'] + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_delete_tags_obj_public(): + key = 'testputtagsacl' + bucket_name = _create_key_with_random_content(key) + client = get_client() + + resource = _make_arn_resource("{}/{}".format(bucket_name, key)) + policy_document = make_json_policy("s3:DeleteObjectTagging", + resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + input_tagset = _create_simple_tagset(10) + response = client.put_object_tagging(Bucket=bucket_name, Key=key, Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + + response = alt_client.delete_object_tagging(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = client.get_object_tagging(Bucket=bucket_name, Key=key) + assert len(response['TagSet']) == 0 + +def test_versioning_bucket_atomic_upload_return_version_id(): + bucket_name = get_new_bucket() + client = get_client() + key = 'bar' + + # for versioning-enabled-bucket, an non-empty version-id should return + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + response = client.put_object(Bucket=bucket_name, Key=key) + version_id = response['VersionId'] + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + for version in versions: + assert version['VersionId'] == version_id + + + # for versioning-default-bucket, no version-id should return. + bucket_name = get_new_bucket() + key = 'baz' + response = client.put_object(Bucket=bucket_name, Key=key) + assert not 'VersionId' in response + + # for versioning-suspended-bucket, no version-id should return. + bucket_name = get_new_bucket() + key = 'baz' + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + response = client.put_object(Bucket=bucket_name, Key=key) + assert not 'VersionId' in response + +def test_versioning_bucket_multipart_upload_return_version_id(): + content_type='text/bla' + objlen = 30 * 1024 * 1024 + + bucket_name = get_new_bucket() + client = get_client() + key = 'bar' + metadata={'foo': 'baz'} + + # for versioning-enabled-bucket, an non-empty version-id should return + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, client=client, content_type=content_type, metadata=metadata) + + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + version_id = response['VersionId'] + + response = client.list_object_versions(Bucket=bucket_name) + versions = response['Versions'] + for version in versions: + assert version['VersionId'] == version_id + + # for versioning-default-bucket, no version-id should return. + bucket_name = get_new_bucket() + key = 'baz' + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, client=client, content_type=content_type, metadata=metadata) + + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert not 'VersionId' in response + + # for versioning-suspended-bucket, no version-id should return + bucket_name = get_new_bucket() + key = 'foo' + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, client=client, content_type=content_type, metadata=metadata) + + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert not 'VersionId' in response + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_get_obj_existing_tag(): + bucket_name = _create_objects(keys=['publictag', 'privatetag', 'invalidtag']) + client = get_client() + + tag_conditional = {"StringEquals": { + "s3:ExistingObjectTag/security" : "public" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + resource, + conditions=tag_conditional) + + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + tagset = [] + tagset.append({'Key': 'security', 'Value': 'public'}) + tagset.append({'Key': 'foo', 'Value': 'bar'}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset2 = [] + tagset2.append({'Key': 'security', 'Value': 'private'}) + + input_tagset = {'TagSet': tagset2} + + response = client.put_object_tagging(Bucket=bucket_name, Key='privatetag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset3 = [] + tagset3.append({'Key': 'security1', 'Value': 'public'}) + + input_tagset = {'TagSet': tagset3} + + response = client.put_object_tagging(Bucket=bucket_name, Key='invalidtag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=bucket_name, Key='publictag') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + e = assert_raises(ClientError, alt_client.get_object, Bucket=bucket_name, Key='privatetag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, alt_client.get_object, Bucket=bucket_name, Key='invalidtag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_get_obj_tagging_existing_tag(): + bucket_name = _create_objects(keys=['publictag', 'privatetag', 'invalidtag']) + client = get_client() + + tag_conditional = {"StringEquals": { + "s3:ExistingObjectTag/security" : "public" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObjectTagging", + resource, + conditions=tag_conditional) + + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + tagset = [] + tagset.append({'Key': 'security', 'Value': 'public'}) + tagset.append({'Key': 'foo', 'Value': 'bar'}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset2 = [] + tagset2.append({'Key': 'security', 'Value': 'private'}) + + input_tagset = {'TagSet': tagset2} + + response = client.put_object_tagging(Bucket=bucket_name, Key='privatetag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset3 = [] + tagset3.append({'Key': 'security1', 'Value': 'public'}) + + input_tagset = {'TagSet': tagset3} + + response = client.put_object_tagging(Bucket=bucket_name, Key='invalidtag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + response = alt_client.get_object_tagging(Bucket=bucket_name, Key='publictag') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # A get object itself should fail since we allowed only GetObjectTagging + e = assert_raises(ClientError, alt_client.get_object, Bucket=bucket_name, Key='publictag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, alt_client.get_object_tagging, Bucket=bucket_name, Key='privatetag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + + e = assert_raises(ClientError, alt_client.get_object_tagging, Bucket=bucket_name, Key='invalidtag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_put_obj_tagging_existing_tag(): + bucket_name = _create_objects(keys=['publictag', 'privatetag', 'invalidtag']) + client = get_client() + + tag_conditional = {"StringEquals": { + "s3:ExistingObjectTag/security" : "public" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:PutObjectTagging", + resource, + conditions=tag_conditional) + + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + tagset = [] + tagset.append({'Key': 'security', 'Value': 'public'}) + tagset.append({'Key': 'foo', 'Value': 'bar'}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset2 = [] + tagset2.append({'Key': 'security', 'Value': 'private'}) + + input_tagset = {'TagSet': tagset2} + + response = client.put_object_tagging(Bucket=bucket_name, Key='privatetag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + # PUT requests with object tagging are a bit wierd, if you forget to put + # the tag which is supposed to be existing anymore well, well subsequent + # put requests will fail + + testtagset1 = [] + testtagset1.append({'Key': 'security', 'Value': 'public'}) + testtagset1.append({'Key': 'foo', 'Value': 'bar'}) + + input_tagset = {'TagSet': testtagset1} + + response = alt_client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + e = assert_raises(ClientError, alt_client.put_object_tagging, Bucket=bucket_name, Key='privatetag', Tagging=input_tagset) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + testtagset2 = [] + testtagset2.append({'Key': 'security', 'Value': 'private'}) + + input_tagset = {'TagSet': testtagset2} + + response = alt_client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # Now try putting the original tags again, this should fail + input_tagset = {'TagSet': testtagset1} + + e = assert_raises(ClientError, alt_client.put_object_tagging, Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_upload_part_copy(): + bucket_name = _create_objects(keys=['public/foo', 'public/bar', 'private/foo']) + client = get_client() + + src_resource = _make_arn_resource("{}/{}".format(bucket_name, "public/*")) + policy_document = make_json_policy("s3:GetObject", src_resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + bucket_name2 = get_new_bucket(alt_client) + + copy_source = {'Bucket': bucket_name, 'Key': 'public/foo'} + + # Create a multipart upload + response = alt_client.create_multipart_upload(Bucket=bucket_name2, Key='new_foo') + upload_id = response['UploadId'] + # Upload a part + response = alt_client.upload_part_copy(Bucket=bucket_name2, Key='new_foo', PartNumber=1, UploadId=upload_id, CopySource=copy_source) + # Complete the multipart upload + response = alt_client.complete_multipart_upload( + Bucket=bucket_name2, Key='new_foo', UploadId=upload_id, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': response['CopyPartResult']['ETag']}]}, + ) + + response = alt_client.get_object(Bucket=bucket_name2, Key='new_foo') + body = _get_body(response) + assert body == 'public/foo' + + copy_source = {'Bucket': bucket_name, 'Key': 'public/bar'} + # Create a multipart upload + response = alt_client.create_multipart_upload(Bucket=bucket_name2, Key='new_foo2') + upload_id = response['UploadId'] + # Upload a part + response = alt_client.upload_part_copy(Bucket=bucket_name2, Key='new_foo2', PartNumber=1, UploadId=upload_id, CopySource=copy_source) + # Complete the multipart upload + response = alt_client.complete_multipart_upload( + Bucket=bucket_name2, Key='new_foo2', UploadId=upload_id, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': response['CopyPartResult']['ETag']}]}, + ) + + response = alt_client.get_object(Bucket=bucket_name2, Key='new_foo2') + body = _get_body(response) + assert body == 'public/bar' + + copy_source = {'Bucket': bucket_name, 'Key': 'private/foo'} + # Create a multipart upload + response = alt_client.create_multipart_upload(Bucket=bucket_name2, Key='new_foo2') + upload_id = response['UploadId'] + # Upload a part + check_access_denied(alt_client.upload_part_copy, Bucket=bucket_name2, Key='new_foo2', PartNumber=1, UploadId=upload_id, CopySource=copy_source) + # Abort the multipart upload + alt_client.abort_multipart_upload(Bucket=bucket_name2, Key='new_foo2', UploadId=upload_id) + + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_put_obj_copy_source(): + bucket_name = _create_objects(keys=['public/foo', 'public/bar', 'private/foo']) + client = get_client() + + src_resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + src_resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + bucket_name2 = get_new_bucket() + + tag_conditional = {"StringLike": { + "s3:x-amz-copy-source" : bucket_name + "/public/*" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name2, "*")) + policy_document = make_json_policy("s3:PutObject", + resource, + conditions=tag_conditional) + + client.put_bucket_policy(Bucket=bucket_name2, Policy=policy_document) + + alt_client = get_alt_client() + copy_source = {'Bucket': bucket_name, 'Key': 'public/foo'} + + alt_client.copy_object(Bucket=bucket_name2, CopySource=copy_source, Key='new_foo') + + # This is possible because we are still the owner, see the grants with + # policy on how to do this right + response = alt_client.get_object(Bucket=bucket_name2, Key='new_foo') + body = _get_body(response) + assert body == 'public/foo' + + copy_source = {'Bucket': bucket_name, 'Key': 'public/bar'} + alt_client.copy_object(Bucket=bucket_name2, CopySource=copy_source, Key='new_foo2') + + response = alt_client.get_object(Bucket=bucket_name2, Key='new_foo2') + body = _get_body(response) + assert body == 'public/bar' + + copy_source = {'Bucket': bucket_name, 'Key': 'private/foo'} + check_access_denied(alt_client.copy_object, Bucket=bucket_name2, CopySource=copy_source, Key='new_foo2') + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_put_obj_copy_source_meta(): + src_bucket_name = _create_objects(keys=['public/foo', 'public/bar']) + client = get_client() + + src_resource = _make_arn_resource("{}/{}".format(src_bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + src_resource) + + client.put_bucket_policy(Bucket=src_bucket_name, Policy=policy_document) + + bucket_name = get_new_bucket() + + tag_conditional = {"StringEquals": { + "s3:x-amz-metadata-directive" : "COPY" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:PutObject", + resource, + conditions=tag_conditional) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + + lf = (lambda **kwargs: kwargs['params']['headers'].update({"x-amz-metadata-directive": "COPY"})) + alt_client.meta.events.register('before-call.s3.CopyObject', lf) + + copy_source = {'Bucket': src_bucket_name, 'Key': 'public/foo'} + alt_client.copy_object(Bucket=bucket_name, CopySource=copy_source, Key='new_foo') + + # This is possible because we are still the owner, see the grants with + # policy on how to do this right + response = alt_client.get_object(Bucket=bucket_name, Key='new_foo') + body = _get_body(response) + assert body == 'public/foo' + + # remove the x-amz-metadata-directive header + def remove_header(**kwargs): + if ("x-amz-metadata-directive" in kwargs['params']['headers']): + del kwargs['params']['headers']["x-amz-metadata-directive"] + + alt_client.meta.events.register('before-call.s3.CopyObject', remove_header) + + copy_source = {'Bucket': src_bucket_name, 'Key': 'public/bar'} + check_access_denied(alt_client.copy_object, Bucket=bucket_name, CopySource=copy_source, Key='new_foo2', Metadata={"foo": "bar"}) + + +@pytest.mark.tagging +@pytest.mark.bucket_policy +def test_bucket_policy_put_obj_acl(): + bucket_name = get_new_bucket() + client = get_client() + + # An allow conditional will require atleast the presence of an x-amz-acl + # attribute a Deny conditional would negate any requests that try to set a + # public-read/write acl + conditional = {"StringLike": { + "s3:x-amz-acl" : "public*" + }} + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + s1 = Statement("s3:PutObject",resource) + s2 = Statement("s3:PutObject", resource, effect="Deny", condition=conditional) + + policy_document = p.add_statement(s1).add_statement(s2).to_json() + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + key1 = 'private-key' + + # if we want to be really pedantic, we should check that this doesn't raise + # and mark a failure, however if this does raise nosetests would mark this + # as an ERROR anyway + response = alt_client.put_object(Bucket=bucket_name, Key=key1, Body=key1) + #response = alt_client.put_object_acl(Bucket=bucket_name, Key=key1, ACL='private') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + key2 = 'public-key' + + lf = (lambda **kwargs: kwargs['params']['headers'].update({"x-amz-acl": "public-read"})) + alt_client.meta.events.register('before-call.s3.PutObject', lf) + + e = assert_raises(ClientError, alt_client.put_object, Bucket=bucket_name, Key=key2, Body=key2) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + +@pytest.mark.bucket_policy +def test_bucket_policy_put_obj_grant(): + + bucket_name = get_new_bucket() + bucket_name2 = get_new_bucket() + client = get_client() + + # In normal cases a key owner would be the uploader of a key in first case + # we explicitly require that the bucket owner is granted full control over + # the object uploaded by any user, the second bucket is where no such + # policy is enforced meaning that the uploader still retains ownership + + main_user_id = get_main_user_id() + alt_user_id = get_alt_user_id() + + owner_id_str = "id=" + main_user_id + s3_conditional = {"StringEquals": { + "s3:x-amz-grant-full-control" : owner_id_str + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:PutObject", + resource, + conditions=s3_conditional) + + resource = _make_arn_resource("{}/{}".format(bucket_name2, "*")) + policy_document2 = make_json_policy("s3:PutObject", resource) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + client.put_bucket_policy(Bucket=bucket_name2, Policy=policy_document2) + + alt_client = get_alt_client() + key1 = 'key1' + + lf = (lambda **kwargs: kwargs['params']['headers'].update({"x-amz-grant-full-control" : owner_id_str})) + alt_client.meta.events.register('before-call.s3.PutObject', lf) + + response = alt_client.put_object(Bucket=bucket_name, Key=key1, Body=key1) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + def remove_header(**kwargs): + if ("x-amz-grant-full-control" in kwargs['params']['headers']): + del kwargs['params']['headers']["x-amz-grant-full-control"] + + alt_client.meta.events.register('before-call.s3.PutObject', remove_header) + + key2 = 'key2' + response = alt_client.put_object(Bucket=bucket_name2, Key=key2, Body=key2) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + acl1_response = client.get_object_acl(Bucket=bucket_name, Key=key1) + + # user 1 is trying to get acl for the object from user2 where ownership + # wasn't transferred + check_access_denied(client.get_object_acl, Bucket=bucket_name2, Key=key2) + + acl2_response = alt_client.get_object_acl(Bucket=bucket_name2, Key=key2) + + assert acl1_response['Grants'][0]['Grantee']['ID'] == main_user_id + assert acl2_response['Grants'][0]['Grantee']['ID'] == alt_user_id + + +@pytest.mark.encryption +def test_put_obj_enc_conflict_c_s3(): + bucket_name = get_new_bucket() + client = get_client() + + # boto3.set_stream_logger(name='botocore') + + key1_str ='testobj' + + sse_client_headers = { + 'x-amz-server-side-encryption' : 'AES256', + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key1_str) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.encryption +def test_put_obj_enc_conflict_c_kms(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-once' + bucket_name = get_new_bucket() + client = get_client() + + # boto3.set_stream_logger(name='botocore') + + key1_str ='testobj' + + sse_client_headers = { + 'x-amz-server-side-encryption' : 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid, + 'x-amz-server-side-encryption-customer-algorithm': 'AES256', + 'x-amz-server-side-encryption-customer-key': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'x-amz-server-side-encryption-customer-key-md5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key1_str) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.encryption +def test_put_obj_enc_conflict_s3_kms(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-once' + bucket_name = get_new_bucket() + client = get_client() + + # boto3.set_stream_logger(name='botocore') + + key1_str ='testobj' + + sse_client_headers = { + 'x-amz-server-side-encryption' : 'AES256', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key1_str) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.encryption +def test_put_obj_enc_conflict_bad_enc_kms(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-once' + bucket_name = get_new_bucket() + client = get_client() + + # boto3.set_stream_logger(name='botocore') + + key1_str ='testobj' + + sse_client_headers = { + 'x-amz-server-side-encryption' : 'aes:kms', # aes != aws + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key=key1_str) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' + +@pytest.mark.encryption +@pytest.mark.bucket_policy +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_bucket_policy_put_obj_s3_noenc(): + bucket_name = get_new_bucket() + client = get_client() + + deny_unencrypted_obj = { + "Null" : { + "s3:x-amz-server-side-encryption": "true" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s = Statement("s3:PutObject", resource, effect="Deny", condition=deny_unencrypted_obj) + policy_document = p.add_statement(s).to_json() + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + key1_str ='testobj' + + check_access_denied(client.put_object, Bucket=bucket_name, Key=key1_str, Body=key1_str) + + response = client.put_object(Bucket=bucket_name, Key=key1_str, ServerSideEncryption='AES256') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + +@pytest.mark.encryption +@pytest.mark.bucket_policy +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_bucket_policy_put_obj_s3_incorrect_algo_sse_s3(): + bucket_name = get_new_bucket() + client = get_client() + + deny_incorrect_algo = { + "StringNotEquals": { + "s3:x-amz-server-side-encryption": "AES256" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s = Statement("s3:PutObject", resource, effect="Deny", condition=deny_incorrect_algo) + policy_document = p.add_statement(s).to_json() + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + key1_str ='testobj' + + check_access_denied(client.put_object, Bucket=bucket_name, Key=key1_str, Body=key1_str, ServerSideEncryption='AES192') + + response = client.put_object(Bucket=bucket_name, Key=key1_str, ServerSideEncryption='AES256') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + +@pytest.mark.encryption +@pytest.mark.bucket_policy +@pytest.mark.sse_s3 +def test_bucket_policy_put_obj_s3_kms(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-twice' + bucket_name = get_new_bucket() + client = get_client() + + deny_incorrect_algo = { + "StringNotEquals": { + "s3:x-amz-server-side-encryption": "AES256" + } + } + + deny_unencrypted_obj = { + "Null" : { + "s3:x-amz-server-side-encryption": "true" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s1 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_incorrect_algo) + s2 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_unencrypted_obj) + policy_document = p.add_statement(s1).add_statement(s2).to_json() + + # boto3.set_stream_logger(name='botocore') + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + key1_str ='testobj' + + #response = client.get_bucket_policy(Bucket=bucket_name) + #print response + + sse_client_headers = { + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': kms_keyid + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + check_access_denied(client.put_object, Bucket=bucket_name, Key=key1_str, Body=key1_str) + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +@pytest.mark.bucket_policy +def test_bucket_policy_put_obj_kms_noenc(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + bucket_name = get_new_bucket() + client = get_client() + + deny_incorrect_algo = { + "StringNotEquals": { + "s3:x-amz-server-side-encryption": "aws:kms" + } + } + + deny_unencrypted_obj = { + "Null" : { + "s3:x-amz-server-side-encryption": "true" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s1 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_incorrect_algo) + s2 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_unencrypted_obj) + policy_document = p.add_statement(s1).add_statement(s2).to_json() + + # boto3.set_stream_logger(name='botocore') + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + key1_str ='testobj' + key2_str ='unicorn' + + #response = client.get_bucket_policy(Bucket=bucket_name) + #print response + + # must do check_access_denied last - otherwise, pending data + # breaks next call... + response = client.put_object(Bucket=bucket_name, Key=key1_str, + ServerSideEncryption='aws:kms', SSEKMSKeyId=kms_keyid) + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == kms_keyid + + check_access_denied(client.put_object, Bucket=bucket_name, Key=key2_str, Body=key2_str) + +@pytest.mark.encryption +@pytest.mark.bucket_policy +def test_bucket_policy_put_obj_kms_s3(): + bucket_name = get_new_bucket() + client = get_client() + + deny_incorrect_algo = { + "StringNotEquals": { + "s3:x-amz-server-side-encryption": "aws:kms" + } + } + + deny_unencrypted_obj = { + "Null" : { + "s3:x-amz-server-side-encryption": "true" + } + } + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s1 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_incorrect_algo) + s2 = Statement("s3:PutObject", resource, effect="Deny", condition=deny_unencrypted_obj) + policy_document = p.add_statement(s1).add_statement(s2).to_json() + + # boto3.set_stream_logger(name='botocore') + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + key1_str ='testobj' + + #response = client.get_bucket_policy(Bucket=bucket_name) + #print response + + sse_client_headers = { + 'x-amz-server-side-encryption' : 'AES256', + } + + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_client_headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + check_access_denied(client.put_object, Bucket=bucket_name, Key=key1_str, Body=key1_str) + +@pytest.mark.tagging +@pytest.mark.bucket_policy +# TODO: remove this fails_on_rgw when I fix it +@pytest.mark.fails_on_rgw +def test_bucket_policy_put_obj_request_obj_tag(): + bucket_name = get_new_bucket() + client = get_client() + + tag_conditional = {"StringEquals": { + "s3:RequestObjectTag/security" : "public" + }} + + p = Policy() + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + s1 = Statement("s3:PutObject", resource, effect="Allow", condition=tag_conditional) + policy_document = p.add_statement(s1).to_json() + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + alt_client = get_alt_client() + key1_str ='testobj' + check_access_denied(alt_client.put_object, Bucket=bucket_name, Key=key1_str, Body=key1_str) + + headers = {"x-amz-tagging" : "security=public"} + lf = (lambda **kwargs: kwargs['params']['headers'].update(headers)) + client.meta.events.register('before-call.s3.PutObject', lf) + #TODO: why is this a 400 and not passing + alt_client.put_object(Bucket=bucket_name, Key=key1_str, Body=key1_str) + +@pytest.mark.tagging +@pytest.mark.bucket_policy +@pytest.mark.fails_on_dbstore +def test_bucket_policy_get_obj_acl_existing_tag(): + bucket_name = _create_objects(keys=['publictag', 'privatetag', 'invalidtag']) + client = get_client() + + tag_conditional = {"StringEquals": { + "s3:ExistingObjectTag/security" : "public" + }} + + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObjectAcl", + resource, + conditions=tag_conditional) + + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + tagset = [] + tagset.append({'Key': 'security', 'Value': 'public'}) + tagset.append({'Key': 'foo', 'Value': 'bar'}) + + input_tagset = {'TagSet': tagset} + + response = client.put_object_tagging(Bucket=bucket_name, Key='publictag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset2 = [] + tagset2.append({'Key': 'security', 'Value': 'private'}) + + input_tagset = {'TagSet': tagset2} + + response = client.put_object_tagging(Bucket=bucket_name, Key='privatetag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + tagset3 = [] + tagset3.append({'Key': 'security1', 'Value': 'public'}) + + input_tagset = {'TagSet': tagset3} + + response = client.put_object_tagging(Bucket=bucket_name, Key='invalidtag', Tagging=input_tagset) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + alt_client = get_alt_client() + response = alt_client.get_object_acl(Bucket=bucket_name, Key='publictag') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # A get object itself should fail since we allowed only GetObjectTagging + e = assert_raises(ClientError, alt_client.get_object, Bucket=bucket_name, Key='publictag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, alt_client.get_object_tagging, Bucket=bucket_name, Key='privatetag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, alt_client.get_object_tagging, Bucket=bucket_name, Key='invalidtag') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1 + } + }} + response = client.put_object_lock_configuration( + Bucket=bucket_name, + ObjectLockConfiguration=conf) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'COMPLIANCE', + 'Years':1 + } + }} + response = client.put_object_lock_configuration( + Bucket=bucket_name, + ObjectLockConfiguration=conf) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_bucket_versioning(Bucket=bucket_name) + assert response['Status'] == 'Enabled' + + +def test_object_lock_put_obj_lock_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'InvalidBucketState' + +def test_object_lock_put_obj_lock_enable_after_create(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1 + } + }} + + # fail if bucket is not versioned + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'InvalidBucketState' + + # fail if versioning is suspended + check_configure_versioning_retry(bucket_name, "Suspended", "Suspended") + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'InvalidBucketState' + + # enable object lock if versioning is enabled + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + response = client.put_object_lock_configuration(Bucket=bucket_name, ObjectLockConfiguration=conf) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock_with_days_and_years(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1, + 'Years':1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock_invalid_days(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':0 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRetentionPeriod' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock_invalid_years(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Years':-1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRetentionPeriod' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock_invalid_mode(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'abc', + 'Years':1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'governance', + 'Years':1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_lock_invalid_status(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Disabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Years':1 + } + }} + e = assert_raises(ClientError, client.put_object_lock_configuration, Bucket=bucket_name, ObjectLockConfiguration=conf) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_suspend_versioning(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + e = assert_raises(ClientError, client.put_bucket_versioning, Bucket=bucket_name, VersioningConfiguration={'Status': 'Suspended'}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 409 + assert error_code == 'InvalidBucketState' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_get_obj_lock(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1 + } + }} + client.put_object_lock_configuration( + Bucket=bucket_name, + ObjectLockConfiguration=conf) + response = client.get_object_lock_configuration(Bucket=bucket_name) + assert response['ObjectLockConfiguration'] == conf + + +def test_object_lock_get_obj_lock_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + e = assert_raises(ClientError, client.get_object_lock_configuration, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'ObjectLockConfigurationNotFoundError' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2140,1,1,tzinfo=pytz.UTC)} + response = client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_object_retention(Bucket=bucket_name, Key=key) + assert response['Retention'] == retention + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + + +def test_object_lock_put_obj_retention_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRequest' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_invalid_mode(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + retention = {'Mode':'governance', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + retention = {'Mode':'abc', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_get_obj_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + response = client.get_object_retention(Bucket=bucket_name, Key=key) + assert response['Retention'] == retention + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_get_obj_retention_iso8601(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + date = datetime.datetime.today() + datetime.timedelta(days=365) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate': date} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + client.meta.events.register('after-call.s3.HeadObject', get_http_response) + client.head_object(Bucket=bucket_name,VersionId=version_id,Key=key) + retain_date = http_response['headers']['x-amz-object-lock-retain-until-date'] + isodate.parse_datetime(retain_date) + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +def test_object_lock_get_obj_retention_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + e = assert_raises(ClientError, client.get_object_retention, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRequest' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_versionid(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, VersionId=version_id, Retention=retention) + response = client.get_object_retention(Bucket=bucket_name, Key=key, VersionId=version_id) + assert response['Retention'] == retention + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_override_default_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + conf = {'ObjectLockEnabled':'Enabled', + 'Rule': { + 'DefaultRetention':{ + 'Mode':'GOVERNANCE', + 'Days':1 + } + }} + client.put_object_lock_configuration( + Bucket=bucket_name, + ObjectLockConfiguration=conf) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + response = client.get_object_retention(Bucket=bucket_name, Key=key) + assert response['Retention'] == retention + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_increase_period(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention1 = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention1) + retention2 = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,3,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention2) + response = client.get_object_retention(Bucket=bucket_name, Key=key) + assert response['Retention'] == retention2 + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_shorten_period(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,3,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_obj_retention_shorten_period_bypass(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + version_id = response['VersionId'] + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,3,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention, BypassGovernanceRetention=True) + response = client.get_object_retention(Bucket=bucket_name, Key=key) + assert response['Retention'] == retention + client.delete_object(Bucket=bucket_name, Key=key, VersionId=version_id, BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_object_with_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId'], BypassGovernanceRetention=True) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_multipart_object_with_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + + key = 'file1' + body = 'abc' + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, ObjectLockMode='GOVERNANCE', + ObjectLockRetainUntilDate=datetime.datetime(2030,1,1,tzinfo=pytz.UTC)) + upload_id = response['UploadId'] + + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=1, Body=body) + parts = [{'ETag': response['ETag'].strip('"'), 'PartNumber': 1}] + + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId'], BypassGovernanceRetention=True) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_object_with_retention_and_marker(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + del_response = client.delete_object(Bucket=bucket_name, Key=key) + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + client.delete_object(Bucket=bucket_name, Key=key, VersionId=del_response['VersionId']) + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + response = client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId'], BypassGovernanceRetention=True) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + +@pytest.mark.fails_on_dbstore +def test_object_lock_multi_delete_object_with_retention(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key1 = 'file1' + key2 = 'file2' + + response1 = client.put_object(Bucket=bucket_name, Body='abc', Key=key1) + response2 = client.put_object(Bucket=bucket_name, Body='abc', Key=key2) + + versionId1 = response1['VersionId'] + versionId2 = response2['VersionId'] + + # key1 is under retention, but key2 isn't. + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key1, Retention=retention) + + delete_response = client.delete_objects( + Bucket=bucket_name, + Delete={ + 'Objects': [ + { + 'Key': key1, + 'VersionId': versionId1 + }, + { + 'Key': key2, + 'VersionId': versionId2 + } + ] + } + ) + + assert len(delete_response['Deleted']) == 1 + assert len(delete_response['Errors']) == 1 + + failed_object = delete_response['Errors'][0] + assert failed_object['Code'] == 'AccessDenied' + assert failed_object['Key'] == key1 + assert failed_object['VersionId'] == versionId1 + + deleted_object = delete_response['Deleted'][0] + assert deleted_object['Key'] == key2 + assert deleted_object['VersionId'] == versionId2 + + delete_response = client.delete_objects( + Bucket=bucket_name, + Delete={ + 'Objects': [ + { + 'Key': key1, + 'VersionId': versionId1 + } + ] + }, + BypassGovernanceRetention=True + ) + + assert( ('Errors' not in delete_response) or (len(delete_response['Errors']) == 0) ) + assert len(delete_response['Deleted']) == 1 + deleted_object = delete_response['Deleted'][0] + assert deleted_object['Key'] == key1 + assert deleted_object['VersionId'] == versionId1 + + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_legal_hold(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + legal_hold = {'Status': 'ON'} + response = client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold=legal_hold) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status':'OFF'}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +def test_object_lock_put_legal_hold_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + legal_hold = {'Status': 'ON'} + e = assert_raises(ClientError, client.put_object_legal_hold, Bucket=bucket_name, Key=key, LegalHold=legal_hold) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRequest' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_put_legal_hold_invalid_status(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + legal_hold = {'Status': 'abc'} + e = assert_raises(ClientError, client.put_object_legal_hold, Bucket=bucket_name, Key=key, LegalHold=legal_hold) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'MalformedXML' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_get_legal_hold(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + legal_hold = {'Status': 'ON'} + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold=legal_hold) + response = client.get_object_legal_hold(Bucket=bucket_name, Key=key) + assert response['LegalHold'] == legal_hold + legal_hold_off = {'Status': 'OFF'} + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold=legal_hold_off) + response = client.get_object_legal_hold(Bucket=bucket_name, Key=key) + assert response['LegalHold'] == legal_hold_off + + +def test_object_lock_get_legal_hold_invalid_bucket(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + e = assert_raises(ClientError, client.get_object_legal_hold, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidRequest' + + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_object_with_legal_hold_on(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status': 'ON'}) + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status':'OFF'}) + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_multipart_object_with_legal_hold_on(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + + key = 'file1' + body = 'abc' + response = client.create_multipart_upload(Bucket=bucket_name, Key=key, ObjectLockLegalHoldStatus='ON') + upload_id = response['UploadId'] + + response = client.upload_part(UploadId=upload_id, Bucket=bucket_name, Key=key, PartNumber=1, Body=body) + parts = [{'ETag': response['ETag'].strip('"'), 'PartNumber': 1}] + + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status':'OFF'}) + +@pytest.mark.fails_on_dbstore +def test_object_lock_delete_object_with_legal_hold_off(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + response = client.put_object(Bucket=bucket_name, Body='abc', Key=key) + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status': 'OFF'}) + response = client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId']) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + +@pytest.mark.fails_on_dbstore +def test_object_lock_get_obj_metadata(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key) + legal_hold = {'Status': 'ON'} + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold=legal_hold) + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':datetime.datetime(2030,1,1,tzinfo=pytz.UTC)} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention) + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ObjectLockMode'] == retention['Mode'] + assert response['ObjectLockRetainUntilDate'] == retention['RetainUntilDate'] + assert response['ObjectLockLegalHoldStatus'] == legal_hold['Status'] + + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status':'OFF'}) + client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId'], BypassGovernanceRetention=True) + + +@pytest.mark.fails_on_dbstore +def test_object_lock_uploading_obj(): + bucket_name = get_new_bucket_name() + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + key = 'file1' + client.put_object(Bucket=bucket_name, Body='abc', Key=key, ObjectLockMode='GOVERNANCE', + ObjectLockRetainUntilDate=datetime.datetime(2030,1,1,tzinfo=pytz.UTC), ObjectLockLegalHoldStatus='ON') + + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ObjectLockMode'] == 'GOVERNANCE' + assert response['ObjectLockRetainUntilDate'] == datetime.datetime(2030,1,1,tzinfo=pytz.UTC) + assert response['ObjectLockLegalHoldStatus'] == 'ON' + client.put_object_legal_hold(Bucket=bucket_name, Key=key, LegalHold={'Status':'OFF'}) + client.delete_object(Bucket=bucket_name, Key=key, VersionId=response['VersionId'], BypassGovernanceRetention=True) + +@pytest.mark.fails_on_dbstore +def test_object_lock_changing_mode_from_governance_with_bypass(): + bucket_name = get_new_bucket_name() + key = 'file1' + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + # upload object with mode=GOVERNANCE + retain_until = datetime.datetime.now(pytz.utc) + datetime.timedelta(seconds=10) + client.put_object(Bucket=bucket_name, Body='abc', Key=key, ObjectLockMode='GOVERNANCE', + ObjectLockRetainUntilDate=retain_until) + # change mode to COMPLIANCE + retention = {'Mode':'COMPLIANCE', 'RetainUntilDate':retain_until} + client.put_object_retention(Bucket=bucket_name, Key=key, Retention=retention, BypassGovernanceRetention=True) + +@pytest.mark.fails_on_dbstore +def test_object_lock_changing_mode_from_governance_without_bypass(): + bucket_name = get_new_bucket_name() + key = 'file1' + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + # upload object with mode=GOVERNANCE + retain_until = datetime.datetime.now(pytz.utc) + datetime.timedelta(seconds=10) + client.put_object(Bucket=bucket_name, Body='abc', Key=key, ObjectLockMode='GOVERNANCE', + ObjectLockRetainUntilDate=retain_until) + # try to change mode to COMPLIANCE + retention = {'Mode':'COMPLIANCE', 'RetainUntilDate':retain_until} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.fails_on_dbstore +def test_object_lock_changing_mode_from_compliance(): + bucket_name = get_new_bucket_name() + key = 'file1' + client = get_client() + client.create_bucket(Bucket=bucket_name, ObjectLockEnabledForBucket=True) + # upload object with mode=COMPLIANCE + retain_until = datetime.datetime.now(pytz.utc) + datetime.timedelta(seconds=10) + client.put_object(Bucket=bucket_name, Body='abc', Key=key, ObjectLockMode='COMPLIANCE', + ObjectLockRetainUntilDate=retain_until) + # try to change mode to GOVERNANCE + retention = {'Mode':'GOVERNANCE', 'RetainUntilDate':retain_until} + e = assert_raises(ClientError, client.put_object_retention, Bucket=bucket_name, Key=key, Retention=retention) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + +@pytest.mark.fails_on_dbstore +def test_copy_object_ifmatch_good(): + bucket_name = get_new_bucket() + client = get_client() + resp = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + client.copy_object(Bucket=bucket_name, CopySource=bucket_name+'/foo', CopySourceIfMatch=resp['ETag'], Key='bar') + response = client.get_object(Bucket=bucket_name, Key='bar') + body = _get_body(response) + assert body == 'bar' + +# TODO: remove fails_on_rgw when https://tracker.ceph.com/issues/40808 is resolved +@pytest.mark.fails_on_rgw +def test_copy_object_ifmatch_failed(): + bucket_name = get_new_bucket() + client = get_client() + client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + e = assert_raises(ClientError, client.copy_object, Bucket=bucket_name, CopySource=bucket_name+'/foo', CopySourceIfMatch='ABCORZ', Key='bar') + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + +# TODO: remove fails_on_rgw when https://tracker.ceph.com/issues/40808 is resolved +@pytest.mark.fails_on_rgw +def test_copy_object_ifnonematch_good(): + bucket_name = get_new_bucket() + client = get_client() + resp = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + e = assert_raises(ClientError, client.copy_object, Bucket=bucket_name, CopySource=bucket_name+'/foo', CopySourceIfNoneMatch=resp['ETag'], Key='bar') + status, error_code = _get_status_and_error_code(e.response) + assert status == 412 + assert error_code == 'PreconditionFailed' + +@pytest.mark.fails_on_dbstore +def test_copy_object_ifnonematch_failed(): + bucket_name = get_new_bucket() + client = get_client() + resp = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + + client.copy_object(Bucket=bucket_name, CopySource=bucket_name+'/foo', CopySourceIfNoneMatch='ABCORZ', Key='bar') + response = client.get_object(Bucket=bucket_name, Key='bar') + body = _get_body(response) + assert body == 'bar' + +# TODO: results in a 404 instead of 400 on the RGW +@pytest.mark.fails_on_rgw +def test_object_read_unreadable(): + bucket_name = get_new_bucket() + client = get_client() + e = assert_raises(ClientError, client.get_object, Bucket=bucket_name, Key='\xae\x8a-') + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert e.response['Error']['Message'] == 'Couldn\'t parse the specified URI.' + +def test_get_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == False + +def test_get_public_acl_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == True + +def test_get_authpublic_acl_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + client.put_bucket_acl(Bucket=bucket_name, ACL='authenticated-read') + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == True + + +def test_get_publicpolicy_acl_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == False + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == True + + +def test_get_nonpublicpolicy_acl_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == False + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ], + "Condition": { + "IpAddress": + {"aws:SourceIp": "10.0.0.0/32"} + } + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == False + + +def test_get_nonpublicpolicy_principal_bucket_policy_status(): + bucket_name = get_new_bucket() + client = get_client() + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::s3tenant1:root"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ], + }] + }) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + resp = client.get_bucket_policy_status(Bucket=bucket_name) + assert resp['PolicyStatus']['IsPublic'] == False + + +def test_bucket_policy_allow_notprincipal(): + bucket_name = get_new_bucket() + client = get_client() + + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "NotPrincipal": {"AWS": "arn:aws:iam::s3tenant1:root"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ], + }] + }) + + e = assert_raises(ClientError, + client.put_bucket_policy, Bucket=bucket_name, Policy=policy_document) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidArgument' or error_code == 'MalformedPolicy' + + +def test_get_undefined_public_block(): + bucket_name = get_new_bucket() + client = get_client() + + # delete the existing public access block configuration + # as AWS creates a default public access block configuration + resp = client.delete_public_access_block(Bucket=bucket_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 204 + + response_code = "" + try: + resp = client.get_public_access_block(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'NoSuchPublicAccessBlockConfiguration' + +def test_get_public_block_deny_bucket_policy(): + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': True, + 'IgnorePublicAcls': True, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + + # make sure we can get the public access block + resp = client.get_public_access_block(Bucket=bucket_name) + assert resp['PublicAccessBlockConfiguration']['BlockPublicAcls'] == access_conf['BlockPublicAcls'] + assert resp['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == access_conf['BlockPublicPolicy'] + assert resp['PublicAccessBlockConfiguration']['IgnorePublicAcls'] == access_conf['IgnorePublicAcls'] + assert resp['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] == access_conf['RestrictPublicBuckets'] + + # make bucket policy to deny access + resource = _make_arn_resource(bucket_name) + policy_document = make_json_policy("s3:GetBucketPublicAccessBlock", + resource, effect="Deny") + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + # check if the access is denied + e = assert_raises(ClientError, client.get_public_access_block, Bucket=bucket_name) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + +def test_put_public_block(): + #client = get_svc_client(svc='s3control', client_config=Config(s3={'addressing_style': 'path'})) + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': True, + 'IgnorePublicAcls': True, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + + resp = client.get_public_access_block(Bucket=bucket_name) + assert resp['PublicAccessBlockConfiguration']['BlockPublicAcls'] == access_conf['BlockPublicAcls'] + assert resp['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == access_conf['BlockPublicPolicy'] + assert resp['PublicAccessBlockConfiguration']['IgnorePublicAcls'] == access_conf['IgnorePublicAcls'] + assert resp['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] == access_conf['RestrictPublicBuckets'] + + +def test_block_public_put_bucket_acls(): + #client = get_svc_client(svc='s3control', client_config=Config(s3={'addressing_style': 'path'})) + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': True, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + + resp = client.get_public_access_block(Bucket=bucket_name) + assert resp['PublicAccessBlockConfiguration']['BlockPublicAcls'] == access_conf['BlockPublicAcls'] + assert resp['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == access_conf['BlockPublicPolicy'] + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name,ACL='public-read') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name,ACL='public-read-write') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket_name,ACL='authenticated-read') + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + + +def test_block_public_object_canned_acls(): + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': True, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': False, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + + # resp = client.get_public_access_block(Bucket=bucket_name) + # assert resp['PublicAccessBlockConfiguration']['BlockPublicAcls'] == access_conf['BlockPublicAcls'] + # assert resp['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == access_conf['BlockPublicPolicy'] + + #FIXME: use empty body until #42208 + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo1', Body='', ACL='public-read') + assert 403 == _get_status(e.response) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo2', Body='', ACL='public-read-write') + assert 403 == _get_status(e.response) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket_name, Key='foo3', Body='', ACL='authenticated-read') + assert 403 == _get_status(e.response) + + client.put_object(Bucket=bucket_name, Key='foo4', Body='', ACL='private') + + +def test_block_public_policy(): + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': False, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + resource) + + check_access_denied(client.put_bucket_policy, Bucket=bucket_name, Policy=policy_document) + + +def test_block_public_policy_with_principal(): + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': False, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + resource, principal={"AWS": "arn:aws:iam::s3tenant1:root"}) + + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + + +@pytest.mark.fails_on_dbstore +def test_block_public_restrict_public_buckets(): + bucket_name = get_new_bucket() + client = get_client() + + # remove any existing public access block configuration + resp = client.delete_public_access_block(Bucket=bucket_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 204 + + # upload an object + response = client.put_object(Bucket=bucket_name, Key='foo', Body='bar') + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # upload a bucket policy that allows public access + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + policy_document = make_json_policy("s3:GetObject", + resource) + resp = client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 204 + + # check if the object is accessible publicly + unauthenticated_client = get_unauthenticated_client() + response = unauthenticated_client.get_object(Bucket=bucket_name, Key='foo') + assert _get_body(response) == 'bar' + + # put a public access block configuration that restricts public buckets + access_conf = {'BlockPublicAcls': False, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': False, + 'RestrictPublicBuckets': True} + resp = client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + # check if the object is no longer accessible publicly + check_access_denied(unauthenticated_client.get_object, Bucket=bucket_name, Key='foo') + + # check if the object is still accessible by the owner + response = client.get_object(Bucket=bucket_name, Key='foo') + assert _get_body(response) == 'bar' + + +def test_ignore_public_acls(): + bucket_name = get_new_bucket() + client = get_client() + alt_client = get_alt_client() + + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + # Public bucket should be accessible + alt_client.list_objects(Bucket=bucket_name) + + client.put_object(Bucket=bucket_name,Key='key1',Body='abcde',ACL='public-read') + resp=alt_client.get_object(Bucket=bucket_name, Key='key1') + assert _get_body(resp) == 'abcde' + + access_conf = {'BlockPublicAcls': False, + 'IgnorePublicAcls': True, + 'BlockPublicPolicy': False, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + resource = _make_arn_resource("{}/{}".format(bucket_name, "*")) + + client.put_bucket_acl(Bucket=bucket_name, ACL='public-read') + # IgnorePublicACLs is true, so regardless this should behave as a private bucket + check_access_denied(alt_client.list_objects, Bucket=bucket_name) + check_access_denied(alt_client.get_object, Bucket=bucket_name, Key='key1') + +def test_put_get_delete_public_block(): + bucket_name = get_new_bucket() + client = get_client() + + access_conf = {'BlockPublicAcls': True, + 'IgnorePublicAcls': True, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False} + + client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=access_conf) + + resp = client.get_public_access_block(Bucket=bucket_name) + assert resp['PublicAccessBlockConfiguration']['BlockPublicAcls'] == access_conf['BlockPublicAcls'] + assert resp['PublicAccessBlockConfiguration']['BlockPublicPolicy'] == access_conf['BlockPublicPolicy'] + assert resp['PublicAccessBlockConfiguration']['IgnorePublicAcls'] == access_conf['IgnorePublicAcls'] + assert resp['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] == access_conf['RestrictPublicBuckets'] + + resp = client.delete_public_access_block(Bucket=bucket_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 204 + + response_code = "" + try: + resp = client.get_public_access_block(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'NoSuchPublicAccessBlockConfiguration' + +def test_multipart_upload_on_a_bucket_with_policy(): + bucket_name = get_new_bucket() + client = get_client() + resource1 = "arn:aws:s3:::" + bucket_name + resource2 = "arn:aws:s3:::" + bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": "*", + "Resource": [ + resource1, + resource2 + ], + }] + }) + key = "foo" + objlen=50*1024*1024 + client.put_bucket_policy(Bucket=bucket_name, Policy=policy_document) + (upload_id, data, parts) = _multipart_upload(bucket_name=bucket_name, key=key, size=objlen, client=client) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def _put_bucket_encryption_s3(client, bucket_name): + """ + enable a default encryption policy on the given bucket + """ + server_side_encryption_conf = { + 'Rules': [ + { + 'ApplyServerSideEncryptionByDefault': { + 'SSEAlgorithm': 'AES256' + } + }, + ] + } + response = client.put_bucket_encryption(Bucket=bucket_name, ServerSideEncryptionConfiguration=server_side_encryption_conf) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + +def _put_bucket_encryption_kms(client, bucket_name): + """ + enable a default encryption policy on the given bucket + """ + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-again' + server_side_encryption_conf = { + 'Rules': [ + { + 'ApplyServerSideEncryptionByDefault': { + 'SSEAlgorithm': 'aws:kms', + 'KMSMasterKeyID': kms_keyid + } + }, + ] + } + response = client.put_bucket_encryption(Bucket=bucket_name, ServerSideEncryptionConfiguration=server_side_encryption_conf) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + +@pytest.mark.sse_s3 +def test_put_bucket_encryption_s3(): + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_s3(client, bucket_name) + +@pytest.mark.encryption +def test_put_bucket_encryption_kms(): + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_kms(client, bucket_name) + + +@pytest.mark.sse_s3 +def test_get_bucket_encryption_s3(): + bucket_name = get_new_bucket() + client = get_client() + + response_code = "" + try: + client.get_bucket_encryption(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'ServerSideEncryptionConfigurationNotFoundError' + + _put_bucket_encryption_s3(client, bucket_name) + + response = client.get_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault']['SSEAlgorithm'] == 'AES256' + + +@pytest.mark.encryption +def test_get_bucket_encryption_kms(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + kms_keyid = 'fool-me-again' + bucket_name = get_new_bucket() + client = get_client() + + response_code = "" + try: + client.get_bucket_encryption(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'ServerSideEncryptionConfigurationNotFoundError' + + _put_bucket_encryption_kms(client, bucket_name) + + response = client.get_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert response['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault']['SSEAlgorithm'] == 'aws:kms' + assert response['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault']['KMSMasterKeyID'] == kms_keyid + + +@pytest.mark.sse_s3 +def test_delete_bucket_encryption_s3(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.delete_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + _put_bucket_encryption_s3(client, bucket_name) + + response = client.delete_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response_code = "" + try: + client.get_bucket_encryption(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'ServerSideEncryptionConfigurationNotFoundError' + + +@pytest.mark.encryption +def test_delete_bucket_encryption_kms(): + bucket_name = get_new_bucket() + client = get_client() + + response = client.delete_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + _put_bucket_encryption_kms(client, bucket_name) + + response = client.delete_bucket_encryption(Bucket=bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response_code = "" + try: + client.get_bucket_encryption(Bucket=bucket_name) + except ClientError as e: + response_code = e.response['Error']['Code'] + + assert response_code == 'ServerSideEncryptionConfigurationNotFoundError' + +def _test_sse_s3_default_upload(file_size): + """ + Test enables bucket encryption. + Create a file of A's of certain size, and use it to set_contents_from_file. + Re-read the contents, and confirm we get same content as input i.e., A's + """ + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_s3(client, bucket_name) + + data = 'A'*file_size + response = client.put_object(Bucket=bucket_name, Key='testobj', Body=data) + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + response = client.get_object(Bucket=bucket_name, Key='testobj') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + body = _get_body(response) + assert body == data + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_upload_1b(): + _test_sse_s3_default_upload(1) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_upload_1kb(): + _test_sse_s3_default_upload(1024) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_upload_1mb(): + _test_sse_s3_default_upload(1024*1024) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_upload_8mb(): + _test_sse_s3_default_upload(8*1024*1024) + +def _test_sse_kms_default_upload(file_size): + """ + Test enables bucket encryption. + Create a file of A's of certain size, and use it to set_contents_from_file. + Re-read the contents, and confirm we get same content as input i.e., A's + """ + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_kms(client, bucket_name) + + data = 'A'*file_size + response = client.put_object(Bucket=bucket_name, Key='testobj', Body=data) + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == kms_keyid + + response = client.get_object(Bucket=bucket_name, Key='testobj') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == kms_keyid + body = _get_body(response) + assert body == data + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_kms_default_upload_1b(): + _test_sse_kms_default_upload(1) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_kms_default_upload_1kb(): + _test_sse_kms_default_upload(1024) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_kms_default_upload_1mb(): + _test_sse_kms_default_upload(1024*1024) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_kms_default_upload_8mb(): + _test_sse_kms_default_upload(8*1024*1024) + + + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_method_head(): + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_s3(client, bucket_name) + + data = 'A'*1000 + key = 'testobj' + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + response = client.head_object(Bucket=bucket_name, Key=key) + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + sse_s3_headers = { + 'x-amz-server-side-encryption': 'AES256', + } + lf = (lambda **kwargs: kwargs['params']['headers'].update(sse_s3_headers)) + client.meta.events.register('before-call.s3.HeadObject', lf) + e = assert_raises(ClientError, client.head_object, Bucket=bucket_name, Key=key) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_multipart_upload(): + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_s3(client, bucket_name) + + key = "multipart_enc" + content_type = 'text/plain' + objlen = 30 * 1024 * 1024 + metadata = {'foo': 'bar'} + enc_headers = { + 'Content-Type': content_type + } + resend_parts = [] + + (upload_id, data, parts) = _multipart_upload_enc(client, bucket_name, key, objlen, + part_size=5*1024*1024, init_headers=enc_headers, part_headers=enc_headers, metadata=metadata, resend_parts=resend_parts) + + lf = (lambda **kwargs: kwargs['params']['headers'].update(enc_headers)) + client.meta.events.register('before-call.s3.CompleteMultipartUpload', lf) + client.complete_multipart_upload(Bucket=bucket_name, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + response = client.list_objects_v2(Bucket=bucket_name, Prefix=key) + assert len(response['Contents']) == 1 + assert response['Contents'][0]['Size'] == objlen + + lf = (lambda **kwargs: kwargs['params']['headers'].update(part_headers)) + client.meta.events.register('before-call.s3.UploadPart', lf) + + response = client.get_object(Bucket=bucket_name, Key=key) + + assert response['Metadata'] == metadata + assert response['ResponseMetadata']['HTTPHeaders']['content-type'] == content_type + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + body = _get_body(response) + assert body == data + size = response['ContentLength'] + assert len(body) == size + + _check_content_using_range(key, bucket_name, data, 1000000) + _check_content_using_range(key, bucket_name, data, 10000000) + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_default_post_object_authenticated_request(): + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_s3(client, bucket_name) + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = { + "expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"), + "conditions": [ + {"bucket": bucket_name}, + ["starts-with", "$key", "foo"], + {"acl": "private"}, + ["starts-with", "$Content-Type", "text/plain"], + ["starts-with", "$x-amz-server-side-encryption", ""], + ["content-length-range", 0, 1024] + ] + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"), + ('file', ('bar'))]) + + r = requests.post(url, files = payload) + assert r.status_code == 204 + + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + body = _get_body(response) + assert body == 'bar' + +@pytest.mark.encryption +@pytest.mark.bucket_encryption +@pytest.mark.fails_on_dbstore +def test_sse_kms_default_post_object_authenticated_request(): + kms_keyid = get_main_kms_keyid() + if kms_keyid is None: + pytest.skip('[s3 main] section missing kms_keyid') + bucket_name = get_new_bucket() + client = get_client() + _put_bucket_encryption_kms(client, bucket_name) + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = { + "expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"), + "conditions": [ + {"bucket": bucket_name}, + ["starts-with", "$key", "foo"], + {"acl": "private"}, + ["starts-with", "$Content-Type", "text/plain"], + ["starts-with", "$x-amz-server-side-encryption", ""], + ["content-length-range", 0, 1024] + ] + } + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + payload = OrderedDict([ ("key" , "foo.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"), + ('file', ('bar'))]) + + r = requests.post(url, files = payload) + assert r.status_code == 204 + + response = client.get_object(Bucket=bucket_name, Key='foo.txt') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == kms_keyid + body = _get_body(response) + assert body == 'bar' + + +def _test_sse_s3_encrypted_upload(file_size): + """ + Test upload of the given size, specifically requesting sse-s3 encryption. + """ + bucket_name = get_new_bucket() + client = get_client() + + data = 'A'*file_size + response = client.put_object(Bucket=bucket_name, Key='testobj', Body=data, ServerSideEncryption='AES256') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + + response = client.get_object(Bucket=bucket_name, Key='testobj') + assert response['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256' + body = _get_body(response) + assert body == data + +@pytest.mark.encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_encrypted_upload_1b(): + _test_sse_s3_encrypted_upload(1) + +@pytest.mark.encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_encrypted_upload_1kb(): + _test_sse_s3_encrypted_upload(1024) + +@pytest.mark.encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_encrypted_upload_1mb(): + _test_sse_s3_encrypted_upload(1024*1024) + +@pytest.mark.encryption +@pytest.mark.sse_s3 +@pytest.mark.fails_on_dbstore +def test_sse_s3_encrypted_upload_8mb(): + _test_sse_s3_encrypted_upload(8*1024*1024) + +def test_get_object_torrent(): + client = get_client() + bucket_name = get_new_bucket() + key = 'Avatar.mpg' + + file_size = 7 * 1024 * 1024 + data = 'A' * file_size + + client.put_object(Bucket=bucket_name, Key=key, Body=data) + + response = None + try: + response = client.get_object_torrent(Bucket=bucket_name, Key=key) + # if successful, verify the torrent contents are different from the body + assert data != _get_body(response) + except ClientError as e: + # accept 404 errors - torrent support may not be configured + status, error_code = _get_status_and_error_code(e.response) + assert status == 404 + assert error_code == 'NoSuchKey' + +@pytest.mark.checksum +def test_object_checksum_sha256(): + bucket = get_new_bucket() + client = get_client() + + key = "myobj" + size = 1024 + body = FakeWriteFile(size, 'A') + sha256sum = 'arcu6553sHVAiX4MjW0j7I7vD4w6R+Gz9Ok0Q9lTa+0=' + response = client.put_object(Bucket=bucket, Key=key, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256=sha256sum) + assert sha256sum == response['ChecksumSHA256'] + + response = client.head_object(Bucket=bucket, Key=key) + assert 'ChecksumSHA256' not in response + response = client.head_object(Bucket=bucket, Key=key, ChecksumMode='ENABLED') + assert sha256sum == response['ChecksumSHA256'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256='bad') + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + +@pytest.mark.checksum +def test_object_checksum_crc64nvme(): + bucket = get_new_bucket() + client = get_client() + + key = "myobj" + size = 1024 + body = FakeWriteFile(size, 'A') + crc64sum = 'Qeh8oXvGiSo=' + response = client.put_object(Bucket=bucket, Key=key, Body=body, ChecksumAlgorithm='CRC64NVME', ChecksumCRC64NVME=crc64sum) + assert crc64sum == response['ChecksumCRC64NVME'] + + response = client.head_object(Bucket=bucket, Key=key) + assert 'ChecksumCRC64NVME' not in response + response = client.head_object(Bucket=bucket, Key=key, ChecksumMode='ENABLED') + assert crc64sum == response['ChecksumCRC64NVME'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, Body=body, ChecksumAlgorithm='CRC64NVME', ChecksumCRC64NVME='bad') + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_checksum_sha256(): + bucket = get_new_bucket() + client = get_client() + + key = "mymultipart" + response = client.create_multipart_upload(Bucket=bucket, Key=key, ChecksumAlgorithm='SHA256') + assert 'SHA256' == response['ChecksumAlgorithm'] + upload_id = response['UploadId'] + + size = 1024 + body = FakeWriteFile(size, 'A') + part_sha256sum = 'arcu6553sHVAiX4MjW0j7I7vD4w6R+Gz9Ok0Q9lTa+0=' + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256=part_sha256sum) + + # should reject the bad request checksum + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket, Key=key, UploadId=upload_id, ChecksumSHA256='bad', MultipartUpload={'Parts': [ + {'ETag': response['ETag'].strip('"'), 'ChecksumSHA256': response['ChecksumSHA256'], 'PartNumber': 1}]}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + + # XXXX re-trying the complete is failing in RGW due to an internal error that appears not caused + # checksums; + # 2024-04-25T17:47:47.991-0400 7f78e3a006c0 0 req 4931907640780566174 0.011000143s s3:complete_multipart check_previously_completed() ERROR: get_obj_attrs() returned ret=-2 + # 2024-04-25T17:47:47.991-0400 7f78e3a006c0 2 req 4931907640780566174 0.011000143s s3:complete_multipart completing + # 2024-04-25T17:47:47.991-0400 7f78e3a006c0 1 req 4931907640780566174 0.011000143s s3:complete_multipart ERROR: either op_ret is negative (execute failed) or target_obj is null, op_ret: -2200 + # -2200 turns into 500, InternalError + + key = "mymultipart2" + response = client.create_multipart_upload(Bucket=bucket, Key=key, ChecksumAlgorithm='SHA256') + assert 'SHA256' == response['ChecksumAlgorithm'] + upload_id = response['UploadId'] + + body = FakeWriteFile(size, 'A') + part_sha256sum = 'arcu6553sHVAiX4MjW0j7I7vD4w6R+Gz9Ok0Q9lTa+0=' + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256=part_sha256sum) + + # should reject the missing part checksum + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket, Key=key, UploadId=upload_id, ChecksumSHA256='bad', MultipartUpload={'Parts': [ + {'ETag': response['ETag'].strip('"'), 'PartNumber': 1}]}) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'BadDigest' + + key = "mymultipart3" + response = client.create_multipart_upload(Bucket=bucket, Key=key, ChecksumAlgorithm='SHA256') + assert 'SHA256' == response['ChecksumAlgorithm'] + upload_id = response['UploadId'] + + body = FakeWriteFile(size, 'A') + part_sha256sum = 'arcu6553sHVAiX4MjW0j7I7vD4w6R+Gz9Ok0Q9lTa+0=' + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256=part_sha256sum) + + composite_sha256sum = 'Ok6Cs5b96ux6+MWQkJO7UBT5sKPBeXBLwvj/hK89smg=-1' + response = client.complete_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id, ChecksumSHA256=composite_sha256sum, MultipartUpload={'Parts': [ + {'ETag': response['ETag'].strip('"'), 'ChecksumSHA256': response['ChecksumSHA256'], 'PartNumber': 1}]}) + assert composite_sha256sum == response['ChecksumSHA256'] + + response = client.head_object(Bucket=bucket, Key=key) + assert 'ChecksumSHA256' not in response + response = client.head_object(Bucket=bucket, Key=key, ChecksumMode='ENABLED') + assert composite_sha256sum == response['ChecksumSHA256'] + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_reupload_checksum_and_etag(): + bucket = get_new_bucket() + client = get_client() + + key = "mymultipart" + response = client.create_multipart_upload(Bucket=bucket, Key=key, ChecksumAlgorithm='SHA256') + assert 'SHA256' == response['ChecksumAlgorithm'] + upload_id = response['UploadId'] + + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + # known MD5/etag and sha256 checksum values + part1_sha256sum = '275VF5loJr1YYawit0XSHREhkFXYkkPKGuoK0x9VKxI=' + part2_sha256sum = 'mrHwOfjTL5Zwfj74F05HOQGLdUb7E5szdCbxgUSq6NM=' + part3_sha256sum = 'Vw7oB/nKQ5xWb3hNgbyfkvDiivl+U+/Dft48nfJfDow=' + + composite_etag = 'b2add96cc9702bbf4efb0ccdfc6b7747-3' + composite_sha256sum = 'uWBwpe1dxI4Vw8Gf0X9ynOdw/SS6VBzfWm9giiv1sf4=-3' + + response1 = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=body1, ChecksumAlgorithm='SHA256', ChecksumSHA256=part1_sha256sum) + response2 = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=2, Body=body2, ChecksumAlgorithm='SHA256', ChecksumSHA256=part2_sha256sum) + response3 = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=3, Body=body3, ChecksumAlgorithm='SHA256', ChecksumSHA256=part3_sha256sum) + + res1 = client.complete_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id, ChecksumSHA256=composite_sha256sum, MultipartUpload={'Parts': [ + {'ETag': response1['ETag'].strip('"'), 'ChecksumSHA256': response1['ChecksumSHA256'], 'PartNumber': 1}, + {'ETag': response2['ETag'].strip('"'), 'ChecksumSHA256': response2['ChecksumSHA256'], 'PartNumber': 2}, + {'ETag': response3['ETag'].strip('"'), 'ChecksumSHA256': response3['ChecksumSHA256'], 'PartNumber': 3}]}) + + assert composite_etag == res1['ETag'].strip('"') + assert composite_sha256sum == res1['ChecksumSHA256'] + + # validate the headers on the (idempotent) attempt to retry completion--they should match for ETag and Checksum (if any) + res2 = client.complete_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id, ChecksumSHA256=composite_sha256sum, MultipartUpload={'Parts': [ + {'ETag': response1['ETag'].strip('"'), 'ChecksumSHA256': response1['ChecksumSHA256'], 'PartNumber': 1}, + {'ETag': response2['ETag'].strip('"'), 'ChecksumSHA256': response2['ChecksumSHA256'], 'PartNumber': 2}, + {'ETag': response3['ETag'].strip('"'), 'ChecksumSHA256': response3['ChecksumSHA256'], 'PartNumber': 3}]}) + + # assert just validated final etag and checksum values match + assert res1['ETag'] == res2['ETag'] + assert res1['ChecksumSHA256'] == res2['ChecksumSHA256'] + +def multipart_checksum_3parts_helper(key=None, checksum_algo=None, checksum_type=None, **kwargs): + + bucket = get_new_bucket() + client = get_client() + + response = client.create_multipart_upload(Bucket=bucket, Key=key, ChecksumAlgorithm=checksum_algo, ChecksumType=checksum_type) + assert checksum_algo == response['ChecksumAlgorithm'] + upload_id = response['UploadId'] + + cksum_arg_name = "Checksum" + checksum_algo + + upload_args = {cksum_arg_name : kwargs['part1_cksum']} + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=kwargs['body1'], ChecksumAlgorithm=checksum_algo, **upload_args) + etag1 = response['ETag'].strip('"') + cksum1 = response[cksum_arg_name] + + upload_args = {cksum_arg_name : kwargs['part2_cksum']} + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=2, Body=kwargs['body2'], ChecksumAlgorithm=checksum_algo, **upload_args) + etag2 = response['ETag'].strip('"') + cksum2 = response[cksum_arg_name] + + upload_args = {cksum_arg_name : kwargs['part3_cksum']} + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=3, Body=kwargs['body3'], ChecksumAlgorithm=checksum_algo, **upload_args) + etag3 = response['ETag'].strip('"') + cksum3 = response[cksum_arg_name] + + upload_args = {cksum_arg_name : kwargs['composite_cksum']} + response = client.complete_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id, MultipartUpload={'Parts': [ + {'ETag': etag1, cksum_arg_name: cksum1, 'PartNumber': 1}, + {'ETag': etag2, cksum_arg_name: cksum2, 'PartNumber': 2}, + {'ETag': etag3, cksum_arg_name: cksum3, 'PartNumber': 3}]}, + **upload_args) + + assert response['ChecksumType'] == checksum_type + assert response[cksum_arg_name] == kwargs['composite_cksum'] + + response1 = client.head_object(Bucket=bucket, Key=key) + assert cksum_arg_name not in response1 + + response2 = client.head_object(Bucket=bucket, Key=key, ChecksumMode='ENABLED') + assert response2['ChecksumType'] == checksum_type + assert response2[cksum_arg_name] == kwargs['composite_cksum'] + + request_attributes = ['Checksum'] + response3 = client.get_object_attributes(Bucket=bucket, Key=key, \ + ObjectAttributes=request_attributes) + assert response3['Checksum']['ChecksumType'] == checksum_type + assert response3['Checksum'][cksum_arg_name] == kwargs['composite_cksum'] + + response4 = client.get_object(Bucket=bucket, Key=key, PartNumber=1) + assert response4['ChecksumType'] == checksum_type + assert response4[cksum_arg_name] == kwargs['part1_cksum'] + + response5 = client.get_object(Bucket=bucket, Key=key, PartNumber=2) + assert response5['ChecksumType'] == checksum_type + assert response5[cksum_arg_name] == kwargs['part2_cksum'] + + response6 = client.get_object(Bucket=bucket, Key=key, PartNumber=3) + assert response6['ChecksumType'] == checksum_type + assert response6[cksum_arg_name] == kwargs['part3_cksum'] + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_use_cksum_helper_sha256(): + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + upload_args = { + "body1" : body1, + "part1_cksum" : '275VF5loJr1YYawit0XSHREhkFXYkkPKGuoK0x9VKxI=', + "body2" : body2, + "part2_cksum" : 'mrHwOfjTL5Zwfj74F05HOQGLdUb7E5szdCbxgUSq6NM=', + "body3" : body3, + "part3_cksum" : 'Vw7oB/nKQ5xWb3hNgbyfkvDiivl+U+/Dft48nfJfDow=', + # the composite OR combined checksum, as appropriate + "composite_cksum" : 'uWBwpe1dxI4Vw8Gf0X9ynOdw/SS6VBzfWm9giiv1sf4=-3', + } + + res = multipart_checksum_3parts_helper(key="mymultipart3", checksum_algo="SHA256", checksum_type="COMPOSITE", **upload_args) + #eof + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_use_cksum_helper_crc64nvme(): + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + upload_args = { + "body1" : body1, + "part1_cksum" : 'L/E4WYn8v98=', + "body2" : body2, + "part2_cksum" : 'xW1l19VobYM=', + "body3" : body3, + "part3_cksum" : 'cK5MnNaWrW4=', + # the composite OR combined checksum, as appropriate + "composite_cksum" : 'i+6LR0y3eFo=', + } + + res = multipart_checksum_3parts_helper(key="mymultipart3", checksum_algo="CRC64NVME", checksum_type="FULL_OBJECT", **upload_args) + #eof + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_use_cksum_helper_crc32(): + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + upload_args = { + "body1" : body1, + "part1_cksum" : 'JRTCyQ==', + "body2" : body2, + "part2_cksum" : 'QoZTGg==', + "body3" : body3, + "part3_cksum" : 'YAgjqw==', + # the composite OR combined checksum, as appropriate + "composite_cksum" : 'WgDhBQ==', + } + + res = multipart_checksum_3parts_helper(key="mymultipart3", checksum_algo="CRC32", checksum_type="FULL_OBJECT", **upload_args) + #eof + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_use_cksum_helper_crc32c(): + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + upload_args = { + "body1" : body1, + "part1_cksum" : 'MDaLrw==', + "body2" : body2, + "part2_cksum" : 'TH4EZg==', + "body3" : body3, + "part3_cksum" : 'Z7mBIQ==', + # the composite OR combined checksum, as appropriate + "composite_cksum" : 'xU+Krw==', + } + + res = multipart_checksum_3parts_helper(key="mymultipart3", checksum_algo="CRC32C", checksum_type="FULL_OBJECT", **upload_args) + #eof + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_multipart_use_cksum_helper_sha1(): + size = 5 * 1024 * 1024 # each part but the last must be at least 5M + + # code to compute checksums for these is in unittest_rgw_cksum + body1 = FakeWriteFile(size, 'A') + body2 = FakeWriteFile(size, 'B') + body3 = FakeWriteFile(size, 'C') + + upload_args = { + "body1" : body1, + "part1_cksum" : 'iIaTCGbm+vdVjNqIMF2S0T7ibMk=', + "body2" : body2, + "part2_cksum" : 'LS/TJ32bAVKEwRu+sE3X7awh/lk=', + "body3" : body3, + "part3_cksum" : '6DDwovUaHwrKNXDMzOGbuvj9kxI=', + # the composite OR combined checksum, as appropriate + "composite_cksum" : 'sizjvY4eud3MrcHdZM3cQ/ol39o=-3', + } + + res = multipart_checksum_3parts_helper(key="mymultipart3", checksum_algo="SHA1", checksum_type="COMPOSITE", **upload_args) + #eof + +@pytest.mark.checksum +def test_post_object_upload_checksum(): + megabytes = 1024 * 1024 + min_size = 0 + max_size = 5 * megabytes + test_payload_size = 2 * megabytes + + bucket_name = get_new_bucket() + client = get_client() + + url = _get_post_url(bucket_name) + utc = pytz.utc + expires = datetime.datetime.now(utc) + datetime.timedelta(seconds=+6000) + + policy_document = {"expiration": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),\ + "conditions": [\ + {"bucket": bucket_name},\ + ["starts-with", "$key", "foo_cksum_test"],\ + {"acl": "private"},\ + ["starts-with", "$Content-Type", "text/plain"],\ + ["content-length-range", min_size, max_size],\ + ]\ + } + + test_payload = b'x' * test_payload_size + + json_policy_document = json.JSONEncoder().encode(policy_document) + bytes_json_policy_document = bytes(json_policy_document, 'utf-8') + policy = base64.b64encode(bytes_json_policy_document) + aws_secret_access_key = get_main_aws_secret_key() + aws_access_key_id = get_main_aws_access_key() + + signature = base64.b64encode(hmac.new(bytes(aws_secret_access_key, 'utf-8'), policy, hashlib.sha1).digest()) + + # good checksum payload (checked via upload from awscli) + payload = OrderedDict([ ("key" , "foo_cksum_test.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),\ + ('x-amz-checksum-sha256', 'aTL9MeXa9HObn6eP93eygxsJlcwdCwCTysgGAZAgE7w='),\ + ('file', (test_payload)),]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 204 + + # bad checksum payload + payload = OrderedDict([ ("key" , "foo_cksum_test.txt"),("AWSAccessKeyId" , aws_access_key_id),\ + ("acl" , "private"),("signature" , signature),("policy" , policy),\ + ("Content-Type" , "text/plain"),\ + ('x-amz-checksum-sha256', 'sailorjerry'),\ + ('file', (test_payload)),]) + + r = requests.post(url, files=payload, verify=get_config_ssl_verify()) + assert r.status_code == 400 + + +def _set_log_bucket_policy_tenant(client, log_tenant, log_bucket_name, src_tenant, src_user, src_buckets, log_prefixes): + statements = [] + for j in range(len(src_buckets)): + if len(log_prefixes) == 1: + prefix = log_prefixes[0] + else: + prefix = log_prefixes[j] + statements.append({ + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": {"Service": "logging.s3.amazonaws.com"}, + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix), + "Condition": { + "ArnLike": {"aws:SourceArn": "arn:aws:s3::{}:{}".format(src_tenant, src_buckets[j])}, + "StringEquals": { + "aws:SourceAccount": src_user + } + } + }) + + policy_document = json.dumps({ + "Version": "2012-10-17", + "Statement": statements + }) + + result = client.put_bucket_policy(Bucket=log_bucket_name, Policy=policy_document) + assert(result['ResponseMetadata']['HTTPStatusCode'] == 204) + + +def _set_log_bucket_policy(client, log_bucket_name, src_buckets, log_prefixes): + _set_log_bucket_policy_tenant(client, "", log_bucket_name, "", get_main_user_id(), src_buckets, log_prefixes) + + +def _has_bucket_logging_extension(): + src_bucket_name = get_new_bucket_name() + log_bucket_name = get_new_bucket_name() + client = get_client() + log_prefix = 'log/' + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': log_prefix, 'LoggingType': 'Journal'} + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + except ParamValidationError as e: + return False + except ClientError as e: + # should fail on non-existing bucket + return True + + return True + + +def _has_target_object_key_format(): + src_bucket_name = get_new_bucket_name() + log_bucket_name = get_new_bucket_name() + client = get_client() + log_prefix = 'log/' + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': log_prefix, 'TargetObjectKeyFormat': {'SimplePrefix': {}}} + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + except ParamValidationError as e: + return False + finally: + # should fail on non-existing bucket + return True + + +import shlex + +def _parse_standard_log_record(record): + record = record.replace('[', '"').replace(']', '"') + chunks = shlex.split(record) + assert len(chunks) == 26 + return { + 'BucketOwner': chunks[0], + 'BucketName': chunks[1], + 'RequestDateTime': chunks[2], + 'RemoteIP': chunks[3], + 'Requester': chunks[4], + 'RequestID': chunks[5], + 'Operation': chunks[6], + 'Key': chunks[7], + 'RequestURI': chunks[8], + 'HTTPStatus': chunks[9], + 'ErrorCode': chunks[10], + 'BytesSent': chunks[11], + 'ObjectSize': chunks[12], + 'TotalTime': chunks[13], + 'TurnAroundTime': chunks[14], + 'Referrer': chunks[15], + 'UserAgent': chunks[16], + 'VersionID': chunks[17], + 'HostID': chunks[18], + 'SigVersion': chunks[19], + 'CipherSuite': chunks[20], + 'AuthType': chunks[21], + 'HostHeader': chunks[22], + 'TLSVersion': chunks[23], + 'AccessPointARN': chunks[24], + 'ACLRequired': chunks[25], + } + + +def _parse_journal_log_record(record): + record = record.replace('[', '"').replace(']', '"') + chunks = shlex.split(record) + assert len(chunks) == 8 + return { + 'BucketOwner': chunks[0], + 'BucketName': chunks[1], + 'RequestDateTime': chunks[2], + 'Operation': chunks[3], + 'Key': chunks[4], + 'ObjectSize': chunks[5], + 'VersionID': chunks[6], + 'ETAG': chunks[7], + } + +def _parse_log_record(record, record_type): + if record_type == 'Standard': + return _parse_standard_log_record(record) + elif record_type == 'Journal': + return _parse_journal_log_record(record) + else: + assert False, 'unknown log record type' + +expected_object_roll_time = 5 + +import logging + +logger = logging.getLogger(__name__) + + +def _verify_records(records, bucket_name, event_type, src_keys, record_type, expected_count, exact_match=False): + keys_found = [] + all_keys = [] + for record in iter(records.splitlines()): + parsed_record = _parse_log_record(record, record_type) + logger.debug('bucket log record: %s', json.dumps(parsed_record, indent=4)) + if bucket_name in record and event_type in record: + all_keys.append(parsed_record['Key']) + for key in src_keys: + if key in record: + keys_found.append(key) + break + logger.info('%d keys found in bucket log: %s', len(all_keys), str(all_keys)) + logger.info('%d keys from the source bucket: %s', len(src_keys), str(src_keys)) + if exact_match: + return len(keys_found) == expected_count and len(keys_found) == len(all_keys) + return len(keys_found) == expected_count + + +def _verify_record_field(records, bucket_name, event_type, object_key, record_type, field_name, expected_value): + for record in iter(records.splitlines()): + if bucket_name in record and event_type in record and object_key in record: + parsed_record = _parse_log_record(record, record_type) + logger.info('bucket log record: %s', json.dumps(parsed_record, indent=4)) + try: + value = parsed_record[field_name] + return expected_value == value + except KeyError: + return False + return False + + +def randcontent(): + letters = string.ascii_lowercase + length = random.randint(10, 1024) + return ''.join(random.choice(letters) for i in range(length)) + + +@pytest.mark.bucket_logging +def test_put_bucket_logging(): + has_extensions = _has_bucket_logging_extension() + has_key_format = _has_target_object_key_format() + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix + } + + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if has_extensions: + logging_enabled['LoggingType'] = 'Standard' + logging_enabled['RecordsBatchSize'] = 0 + if has_key_format: + # default value for key prefix is returned + logging_enabled['TargetObjectKeyFormat'] = {'SimplePrefix': {}} + assert response['LoggingEnabled'] == logging_enabled + + if has_key_format: + # with simple target object prefix + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': 'log/', + 'TargetObjectKeyFormat': { + 'SimplePrefix': {} + } + } + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if has_extensions: + logging_enabled['LoggingType'] = 'Standard' + logging_enabled['RecordsBatchSize'] = 0 + assert response['LoggingEnabled'] == logging_enabled + + # with partitioned target object prefix + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': 'log/', + 'TargetObjectKeyFormat': { + 'PartitionedPrefix': { + 'PartitionDateSource': 'DeliveryTime' + } + } + } + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if has_extensions: + logging_enabled['LoggingType'] = 'Standard' + logging_enabled['RecordsBatchSize'] = 0 + assert response['LoggingEnabled'] == logging_enabled + + # with target grant (not implemented in RGW) + main_display_name = get_main_display_name() + main_user_id = get_main_user_id() + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': 'log/', + 'TargetGrants': [{'Grantee': {'DisplayName': main_display_name, 'ID': main_user_id,'Type': 'CanonicalUser'},'Permission': 'FULL_CONTROL'}] + } + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if has_extensions: + logging_enabled['LoggingType'] = 'Standard' + logging_enabled['RecordsBatchSize'] = 0 + # target grants are not implemented + logging_enabled.pop('TargetGrants') + if has_key_format: + # default value for key prefix is returned + logging_enabled['TargetObjectKeyFormat'] = {'SimplePrefix': {}} + assert response['LoggingEnabled'] == logging_enabled + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_mtime(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix + } + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + mtime = response['ResponseMetadata']['HTTPHeaders']['last-modified'] + + # wait and set the same conf - mtime should be the same + time.sleep(1) + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert mtime == response['ResponseMetadata']['HTTPHeaders']['last-modified'] + + # wait and change conf - new mtime should be larger + time.sleep(1) + prefix = 'another-log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix + } + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert mtime < response['ResponseMetadata']['HTTPHeaders']['last-modified'] + mtime = response['ResponseMetadata']['HTTPHeaders']['last-modified'] + + # wait and disable/enable conf - new mtime should be larger + time.sleep(1) + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert mtime < response['ResponseMetadata']['HTTPHeaders']['last-modified'] + + +def _flush_logs(client, src_bucket_name, dummy_key="dummy"): + if _has_bucket_logging_extension(): + result = client.post_bucket_logging(Bucket=src_bucket_name) + assert result['ResponseMetadata']['HTTPStatusCode'] == 200 + return result['FlushedLoggingObject'] + else: + time.sleep(expected_object_roll_time*1.1) + client.put_object(Bucket=src_bucket_name, Key=dummy_key, Body='dummy') + return None + + +def _verify_logging_key(key_format, key, expected_prefix, expected_dt, expected_src_account, expected_src_region, expected_src_bucket): + # NOTE: prefix value has to be terminated with non alphanumeric character + # for the regex to work properly + if key_format == 'SimplePrefix': + #[DestinationPrefix][YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString] + pattern = r'^(.+?)(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(.+)$' + no_prefix_pattern = r'^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(.+)$' + elif key_format == 'PartitionedPrefix': + # [DestinationPrefix][SourceAccountId]/[SourceRegion]/[SourceBucket]/[YYYY]/[MM]/[DD]/[YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString] + pattern = r'^(.+?)([^/]+)/([^/]+)/([^/]+)/(\d{4})/(\d{2})/(\d{2})/(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(.+)$' + no_prefix_pattern = r'^([^/]+)/([^/]+)/([^/]+)/(\d{4})/(\d{2})/(\d{2})/(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(.+)$' + else: + assert False, 'unknown key format: {}'.format(key_format) + + match = re.match(no_prefix_pattern, key) + if not match: + match = re.match(pattern, key) + assert match + match_index = 1 + prefix = match.group(match_index) + else: + prefix = '' + match_index = 0 + + _year = None + _month = None + _day = None + assert prefix == expected_prefix + if key_format == 'PartitionedPrefix': + match_index += 1 + src_account = match.group(match_index) + assert src_account == expected_src_account + match_index += 1 + src_region = match.group(match_index) + assert src_region == expected_src_region + match_index += 1 + src_bucket = match.group(match_index) + assert src_bucket == expected_src_bucket + match_index += 1 + _year = int(match.group(match_index)) + match_index += 1 + _month = int(match.group(match_index)) + match_index += 1 + _day = int(match.group(match_index)) + + match_index += 1 + year = int(match.group(match_index)) + if _year: + assert year == _year + match_index += 1 + month = int(match.group(match_index)) + if _month: + assert month == _month + match_index += 1 + day = int(match.group(match_index)) + if _day: + assert day == _day + match_index += 1 + hour = int(match.group(match_index)) + match_index += 1 + minute = int(match.group(match_index)) + match_index += 1 + second = int(match.group(match_index)) + match_index += 1 + unique_string = match.group(match_index) + try: + dt = datetime.datetime(year, month, day, hour, minute, second) + assert abs(dt - expected_dt) <= datetime.timedelta(seconds=5) + except ValueError: + # Invalid date/time values + assert False, 'invalid date/time values in key: {}'.format(key) + + +def _bucket_logging_object_name(key_format, prefix): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix + } + if key_format == 'SimplePrefix': + logging_enabled['TargetObjectKeyFormat'] = {'SimplePrefix': {}} + elif key_format == 'PartitionedPrefix': + logging_enabled['TargetObjectKeyFormat'] = { + 'PartitionedPrefix': { + 'PartitionDateSource': 'DeliveryTime' + } + } + else: + assert False, 'unknown key format: %s' % key_format + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + user_id = get_main_user_id() + timestamp = datetime.datetime.now() + _verify_logging_key(key_format, keys[0], prefix, timestamp, user_id, 'default', src_bucket_name) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_simple_key(): + _bucket_logging_object_name('SimplePrefix', 'log/') + _bucket_logging_object_name('SimplePrefix', '') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_partitioned_key(): + _bucket_logging_object_name('PartitionedPrefix', 'log/') + _bucket_logging_object_name('PartitionedPrefix', '') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_bucket_auth_type(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + key = 'my-test-object' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + client.put_object(Bucket=src_bucket_name, Key=key, Body=randcontent()) + client.put_object_acl(ACL='public-read',Bucket=src_bucket_name, Key=key) + + response = client.list_objects_v2(Bucket=src_bucket_name) + keys = _get_keys(response) + + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # AuthType AuthHeader: + response = client.get_object(Bucket=src_bucket_name, Key=key) + client.list_objects_v2(Bucket=src_bucket_name) + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + + for log_key in log_keys: + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "AuthType", "AuthHeader") + + + #AuthType "-" (unauthenticated) + unauthenticated_client = get_unauthenticated_client() + unauthenticated_client.get_object(Bucket=src_bucket_name, Key=key) + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + + log_key = log_keys[-1] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "AuthType", "-") + + #AuthType "QueryString" (presigned SigV4) + params = {'Bucket': src_bucket_name, 'Key': key} + url = client.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=100000, HttpMethod='GET') + + res = requests.options(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 400 + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 200 + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + + log_key = log_keys[-1] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "AuthType", "QueryString") + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "SigVersion", "SigV4") + + #AuthType "QueryString" (presigned SigV2) + client_v2 = get_v2_client() + params = {'Bucket': src_bucket_name, 'Key': key} + url = client_v2.generate_presigned_url(ClientMethod='get_object', Params=params, ExpiresIn=100000, HttpMethod='GET') + + res = requests.options(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 400 + + res = requests.get(url, verify=get_config_ssl_verify()).__dict__ + assert res['status_code'] == 200 + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + + log_key = log_keys[-1] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "AuthType", "QueryString") + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "SigVersion", "SigV2") + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_request_id(): + """verify that the standard access log RequestID field matches the x-amz-request-id response header""" + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + key = 'my-test-object' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + # enable standard logging + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # capture x-amz-request-id from the put_object response + response = client.put_object(Bucket=src_bucket_name, Key=key, Body=randcontent()) + request_id = response['ResponseMetadata']['HTTPHeaders'].get('x-amz-request-id') + assert request_id is not None, 'failed to read x-amz-request-id from response headers' + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + + for log_key in log_keys: + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.PUT.OBJECT', key, "Standard", "RequestID", request_id) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_bucket_logging_requester_assumed_role(): + """verify the standard access log Requester field contains the assumed-role ARN + for requests made with STS temporary credentials""" + iam_client = get_iam_client() + sts_client = get_sts_client() + alt_user_id = get_alt_user_id() + default_endpoint = get_config_endpoint() + role_session_name = get_parameter_name() + + # create an IAM role assumable by the alt user + trust_policy = json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": [f"arn:aws:iam:::user/{alt_user_id}"]}, + "Action": ["sts:AssumeRole"] + }] + }) + role_name = get_parameter_name() + role_response = iam_client.create_role(Path='/', RoleName=role_name, AssumeRolePolicyDocument=trust_policy) + role_arn = role_response['Role']['Arn'] + + try: + role_policy = json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:*", + "Resource": "*" + }] + }) + iam_client.put_role_policy(RoleName=role_name, PolicyName='S3FullAccess', PolicyDocument=role_policy) + + # assume the role + resp = sts_client.assume_role(RoleArn=role_arn, RoleSessionName=role_session_name) + + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + creds = resp['Credentials'] + role_s3 = boto3.client( + 's3', + aws_access_key_id=creds['AccessKeyId'], + aws_secret_access_key=creds['SecretAccessKey'], + aws_session_token=creds['SessionToken'], + endpoint_url=default_endpoint, + region_name='') + + src_bucket_name = get_new_bucket_name() + log_bucket_name = get_new_bucket_name() + role_s3.create_bucket(Bucket=src_bucket_name) + role_s3.create_bucket(Bucket=log_bucket_name) + prefix = 'log/' + _set_log_bucket_policy_tenant(role_s3, "", log_bucket_name, "", alt_user_id, [src_bucket_name], [prefix]) + + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + role_s3.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={'LoggingEnabled': logging_enabled}) + + key = 'my-test-object' + role_s3.put_object(Bucket=src_bucket_name, Key=key, Body=randcontent()) + + _flush_logs(role_s3, src_bucket_name) + response = role_s3.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + + response = role_s3.get_object(Bucket=log_bucket_name, Key=log_keys[0]) + body = _get_body(response) + expected_substring = f'assumed-role/{role_name}/{role_session_name}' + found = False + for record in body.splitlines(): + if src_bucket_name not in record or 'REST.PUT.OBJECT' not in record or key not in record: + continue + parsed = _parse_log_record(record, "Standard") + requester = parsed['Requester'] + assert requester.startswith('arn:aws:sts::'), f"Requester should be an STS ARN, got: {requester}" + assert expected_substring in requester, f"Requester missing {expected_substring}, got: {requester}" + found = True + break + assert found, "no PUT log record found for the assumed-role request" + finally: + nuke_role(iam_client, role_name) + + +def _bucket_logging_key_filter(log_type): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'LoggingType': log_type, + 'TargetPrefix': prefix, + 'ObjectRollTime': expected_object_roll_time, + 'TargetObjectKeyFormat': {'SimplePrefix': {}}, + 'RecordsBatchSize': 0, + 'Filter': + { + 'Key': { + 'FilterRules': [ + {'Name': 'prefix', 'Value': 'test/'}, + {'Name': 'suffix', 'Value': '.txt'} + ] + } + } + } + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if log_type == 'Journal': + assert response['LoggingEnabled'] == logging_enabled + elif log_type == 'Standard': + print('TODO') + else: + assert False, 'unknown log type: %s' % log_type + + names = [] + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + if log_type == 'Standard': + # standard log records are not filtered + names.append(name) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + for j in range(num_keys): + name = 'test/'+'myobject'+str(j)+'.txt' + names.append(name) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + expected_count = len(names) + + flushed_obj = _flush_logs(client, src_bucket_name, dummy_key="test/dummy.txt") + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + for key in keys: + if flushed_obj is not None: + assert key == flushed_obj + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', names, log_type, expected_count, exact_match=True) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_bucket_acl_required(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + key = 'my-test-object' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + client.put_object(Bucket=src_bucket_name, Key=key, Body=randcontent()) + client.put_bucket_acl(ACL='public-read',Bucket=src_bucket_name) + + response = client.list_objects_v2(Bucket=src_bucket_name) + keys = _get_keys(response) + + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + #This will require ACLs + alt_client = get_alt_client() + response = alt_client.list_objects_v2(Bucket=src_bucket_name) + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + + for log_key in log_keys: + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.BUCKET', ["-"], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.BUCKET', "-", "Standard", "ACLRequired", "Yes") + + #Set a bucket policy that will allow access without requiring ACLs + resource1 = "arn:aws:s3:::" + src_bucket_name + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": [ + "{}".format(resource1), + ] + }] + }) + + response = client.put_bucket_policy(Bucket=src_bucket_name, Policy=policy_document) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = alt_client.list_objects_v2(Bucket=src_bucket_name) + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + + log_key = log_keys[-1] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.BUCKET', ["-"], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.BUCKET', "-", "Standard", "ACLRequired", "-") + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_object_acl_required(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + key = 'my-test-object' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + client.put_object(Bucket=src_bucket_name, Key=key, Body=randcontent()) + client.put_object_acl(ACL='public-read',Bucket=src_bucket_name, Key=key) + + response = client.list_objects_v2(Bucket=src_bucket_name) + keys = _get_keys(response) + + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + #This will require ACLs + alt_client = get_alt_client() + response = alt_client.get_object(Bucket=src_bucket_name, Key=key) + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + + for log_key in log_keys: + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "ACLRequired", "Yes") + + #Set a bucket policy that will allow access to the object without requiring ACLs + resource1 = "arn:aws:s3:::" + src_bucket_name + resource2 = "arn:aws:s3:::" + src_bucket_name + "/*" + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:GetObject", + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + + response = client.put_bucket_policy(Bucket=src_bucket_name, Policy=policy_document) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + response = alt_client.get_object(Bucket=src_bucket_name, Key=key) + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + + log_key = log_keys[-1] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', [key], "Standard", 1) + assert _verify_record_field(body, src_bucket_name, 'REST.GET.OBJECT', key, "Standard", "ACLRequired", "-") + + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_key_filter_s(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_key_filter('Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_key_filter_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_key_filter('Journal') + + +def _post_bucket_logging(client, src_bucket_name, flushed_objs): + result = client.post_bucket_logging(Bucket=src_bucket_name) + assert result['ResponseMetadata']['HTTPStatusCode'] == 200 + flushed_objs[src_bucket_name] = result['FlushedLoggingObject'] + + +def _bucket_logging_flush(logging_type, single_prefix, concurrency, num_keys): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + num_buckets = 5 + buckets = [] + log_prefixes = [] + longer_time = expected_object_roll_time*10 + for j in range(num_buckets): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + buckets.append(src_bucket_name) + if single_prefix: + log_prefixes.append('log/') + else: + log_prefixes.append(src_bucket_name+'/') + + _set_log_bucket_policy(client, log_bucket_name, buckets, log_prefixes) + + for j in range(num_buckets): + logging_enabled = {'TargetBucket': log_bucket_name, + 'ObjectRollTime': longer_time, + 'LoggingType': logging_type, + 'TargetPrefix': log_prefixes[j] + } + + response = client.put_bucket_logging(Bucket=buckets[j], BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + src_names = [] + for j in range(num_keys): + src_names.append('myobject'+str(j)) + + for src_bucket_name in buckets: + for name in src_names: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + client.delete_object(Bucket=src_bucket_name, Key=name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + + t = [] + flushed_objs = {} + for src_bucket_name in buckets: + if concurrency: + thr = threading.Thread(target = _post_bucket_logging, + args=(client, src_bucket_name, flushed_objs)) + thr.start() + t.append(thr) + else: + result = client.post_bucket_logging(Bucket=src_bucket_name) + assert result['ResponseMetadata']['HTTPStatusCode'] == 200 + flushed_objs[src_bucket_name] = result['FlushedLoggingObject'] + if single_prefix: + break + + _do_wait_completion(t) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + + if single_prefix: + assert len(keys) == 1 + assert len(flushed_objs) == 1 + else: + assert len(keys) == num_buckets + assert len(flushed_objs) >= num_buckets + + for key in keys: + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + found = False + for j in range(num_buckets): + prefix = log_prefixes[j] + if key.startswith(prefix): + flushed_obj = flushed_objs.get(buckets[j]) + if flushed_obj is not None: + assert key == flushed_obj + found = True + assert _verify_records(body, buckets[j], 'REST.PUT.OBJECT', src_names, logging_type, num_keys) + assert _verify_records(body, buckets[j], 'REST.DELETE.OBJECT', src_names, logging_type, num_keys) + assert found + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_flush_empty(): + # testing empty commits + _bucket_logging_flush('Journal', False, False, 0) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_flush_j(): + _bucket_logging_flush('Journal', False, False, 10) + _bucket_logging_flush('Journal', False, False, 100) + # testing empty commits after some activity + _bucket_logging_flush('Journal', False, False, 0) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_flush_s(): + _bucket_logging_flush('Standard', False, False, 10) + _bucket_logging_flush('Standard', False, False, 100) + # no empty commits in "standard" mode + # the POST command itself will be logged + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_flush_j_single(): + _bucket_logging_flush('Journal', True, False, 10) + _bucket_logging_flush('Journal', True, False, 100) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_flush_s_single(): + _bucket_logging_flush('Standard', True, False, 10) + _bucket_logging_flush('Standard', True, False, 100) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_concurrent_flush_j(): + _bucket_logging_flush('Journal', False, True, 10) + _bucket_logging_flush('Journal', False, True, 100) + _bucket_logging_flush('Journal', False, True, 0) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_concurrent_flush_s(): + _bucket_logging_flush('Standard', False, True, 10) + _bucket_logging_flush('Standard', False, True, 100) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_concurrent_flush_j_single(): + _bucket_logging_flush('Journal', True, True, 10) + _bucket_logging_flush('Journal', True, True, 100) + _bucket_logging_flush('Journal', True, True, 0) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_concurrent_flush_s_single(): + _bucket_logging_flush('Standard', True, True, 10) + _bucket_logging_flush('Standard', True, True, 100) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_put_and_flush(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client_config = botocore.config.Config(max_pool_connections=100) + client = get_client(client_config=client_config) + + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [log_prefix]) + + logging_enabled = {'TargetBucket': log_bucket_name, + 'LoggingType': 'Journal', + 'TargetPrefix': log_prefix + } + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 300 + flush_rate = 10 + put_threads = [] + flush_threads = [] + src_names = [] + for j in range(num_keys): + name = 'myobject'+str(j) + src_names.append(name) + put_thr = threading.Thread(target=client.put_object, + kwargs={'Bucket': src_bucket_name, + 'Key': name, + 'Body': randcontent()}) + put_thr.start() + put_threads.append(put_thr) + + if j % flush_rate == 0: + flush_thr = threading.Thread(target = client.post_bucket_logging, + kwargs={'Bucket': src_bucket_name}) + flush_thr.start() + flush_threads.append(flush_thr) + + _do_wait_completion(put_threads) + _do_wait_completion(flush_threads) + + # making sure everything is flushed synchronously + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + + body = '' + prev_key = '' + for key in keys: + logger.info('bucket log record: %s', key) + assert key > prev_key + prev_key = key + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_names, 'Journal', num_keys) + + +@pytest.mark.bucket_logging +def test_put_bucket_logging_errors(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name1 = get_new_bucket_name() + log_bucket1 = get_new_bucket_resource(name=log_bucket_name1) + client = get_client() + prefix = 'log/' + + # invalid source bucket + try: + response = client.put_bucket_logging(Bucket=src_bucket_name+'kaboom', BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name1, 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'NoSuchBucket' + + # invalid log bucket + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name1+'kaboom', 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'NoSuchKey' + + # log bucket has bucket logging + log_bucket_name2 = get_new_bucket_name() + log_bucket2 = get_new_bucket_resource(name=log_bucket_name2) + _set_log_bucket_policy(client, log_bucket_name1, [log_bucket_name2], [prefix]) + response = client.put_bucket_logging(Bucket=log_bucket_name2, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name1, 'TargetPrefix': prefix}, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _set_log_bucket_policy(client, log_bucket_name2, [src_bucket_name], [prefix]) + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name2, 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'InvalidArgument' + + # invalid partition prefix + if _has_target_object_key_format(): + logging_enabled = { + 'TargetBucket': log_bucket_name1, + 'TargetPrefix': prefix, + 'TargetObjectKeyFormat': { + 'PartitionedPrefix': { + 'PartitionDateSource': 'kaboom' + } + } + } + try: + _set_log_bucket_policy(client, log_bucket_name1, [src_bucket_name], [prefix]) + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'MalformedXML' + + # log bucket is the same as source bucket + _set_log_bucket_policy(client, src_bucket_name, [src_bucket_name], [prefix]) + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': src_bucket_name, 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'InvalidArgument' + + # log bucket is encrypted + _put_bucket_encryption_s3(client, log_bucket_name1) + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name1, 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'InvalidArgument' + + # "requester pays" is set on log bucket + log_bucket_name3 = get_new_bucket_name() + log_bucket3 = get_new_bucket_resource(name=log_bucket_name3) + _set_log_bucket_policy(client, log_bucket_name3, [src_bucket_name], [prefix]) + response = client.put_bucket_request_payment(Bucket=log_bucket_name3, + RequestPaymentConfiguration={'Payer': 'Requester'}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name3, 'TargetPrefix': prefix}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'InvalidArgument' + + # invalid log type + if _has_bucket_logging_extension(): + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name1, 'TargetPrefix': prefix, 'LoggingType': 'kaboom'}, + }) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'MalformedXML' + + +def _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix): + try: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'AccessDenied' + + +@pytest.mark.bucket_logging +def test_bucket_logging_owner(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + alt_client = get_alt_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + # set policy to allow all action on source bucket + policy_document = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:PutBucketLogging"], + }] + }) + + response = client.put_bucket_policy(Bucket=src_bucket_name, Policy=policy_document) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + # try set bucket logging from another user + _verify_access_denied(alt_client, src_bucket_name, log_bucket_name, prefix) + + # set bucket logging from the bucket owner user + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # try remove bucket logging from another user + try: + response = alt_client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={}) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'AccessDenied' + + +def _set_bucket_policy(client, log_bucket_name, policy): + policy_document = json.dumps(policy) + response = client.put_bucket_policy(Bucket=log_bucket_name, Policy=policy_document) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + +@pytest.mark.bucket_logging +def test_put_bucket_logging_permissions(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + log_tenant = "" + src_tenant = "" + src_user = get_main_user_id() + + # missing log bucket policy + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + + policy = { + "Version": "2012-10-17", + "Statement": [{ + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": {"Service": "logging.s3.amazonaws.com"}, + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix), + "Condition": { + "ArnLike": {"aws:SourceArn": "arn:aws:s3::{}:{}".format(src_tenant, src_bucket_name)}, + "StringEquals": { + "aws:SourceAccount": "{}${}".format(src_tenant, src_user) if src_tenant else src_user + } + } + }] + } + + # missing service principal + policy['Statement'][0]['Principal'] = {"AWS": "*"} + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + + # invalid service principal + policy['Statement'][0]['Principal'] = {"Service": "logging.s3.amazonaws.com"+"kaboom"} + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + # set valid principal + policy['Statement'][0]['Principal'] = {"Service": "logging.s3.amazonaws.com"} + + # invalid action + policy['Statement'][0]['Action'] = ["s3:GetObject"] + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + # set valid action + policy['Statement'][0]['Action'] = ["s3:PutObject"] + + # invalid resource + policy['Statement'][0]['Resource'] = "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, "kaboom") + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + # set valid resource + policy['Statement'][0]['Resource'] = "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix) + + # invalid source bucket + policy['Statement'][0]['Condition']['ArnLike']['aws:SourceArn'] = "arn:aws:s3::{}:{}".format(src_tenant, "kaboom") + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + policy['Statement'][0]['Condition']['ArnLike']['aws:SourceArn'] = "arn:aws:s3::{}:{}".format("kaboom", src_bucket) + # set valid source bucket + policy['Statement'][0]['Condition']['ArnLike']['aws:SourceArn'] = "arn:aws:s3::{}:{}".format(src_tenant, src_bucket) + + # invalid source account + src_user = "kaboom" + policy['Statement'][0]['Condition']['StringEquals']['aws:SourceAccount'] = "{}${}".format(src_tenant, src_user) if src_tenant else src_user + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + src_user = get_main_user_id() + src_tenant = "kaboom" + policy['Statement'][0]['Condition']['StringEquals']['aws:SourceAccount'] = "{}${}".format(src_tenant, src_user) if src_tenant else src_user + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(client, src_bucket_name, log_bucket_name, prefix) + + +def _put_bucket_logging_policy_wildcard(add_objects): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + alt_client = get_alt_client() + alt_src_bucket_name = get_new_bucket_name() + alt_src_bucket = get_new_bucket(client=alt_client, name=alt_src_bucket_name) + prefix = 'log/' + log_tenant = "" + src_tenant = "" + src_user = get_main_user_id() + alt_user = get_alt_user_id() + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": {"Service": "logging.s3.amazonaws.com"}, + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix), + "Condition": { + "ArnLike": {"aws:SourceArn": "arn:aws:s3::{}:{}".format(src_tenant, src_bucket_name)}, + "StringLike": { + "aws:SourceAccount": "{}${}".format(src_tenant, src_user) if src_tenant else src_user + } + } + }, + { + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": {"Service": "logging.s3.amazonaws.com"}, + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix), + "Condition": { + "ArnLike": {"aws:SourceArn": "arn:aws:s3::{}:{}".format(src_tenant, alt_src_bucket_name)}, + "StringLike": { + "aws:SourceAccount": "{}${}".format(src_tenant, src_user) if src_tenant else alt_user + } + } + } + ] + } + + # verify policy is ok + _set_bucket_policy(client, log_bucket_name, policy) + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = alt_client.put_bucket_logging(Bucket=alt_src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # remove the 2nd statement from the policy + # and make sure it fails + policy['Statement'].pop() + _set_bucket_policy(client, log_bucket_name, policy) + _verify_access_denied(alt_client, alt_src_bucket_name, log_bucket_name, prefix) + + # use wildcards account/bucket + policy['Statement'][0]['Condition']['StringLike']['aws:SourceAccount'] = "*" + policy['Statement'][0]['Condition']['ArnLike']['aws:SourceArn'] = "arn:aws:s3::{}:*".format(src_tenant) + _set_bucket_policy(client, log_bucket_name, policy) + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = alt_client.put_bucket_logging(Bucket=alt_src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + if not add_objects: + return + + # put objects when policy is ok + num_keys = 5 + src_keys = [] + for j in range(num_keys): + name = 'myobject_1_'+str(j) + src_keys.append(name) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + name = 'myobject_2_'+str(j) + src_keys.append(name) + alt_client.put_object(Bucket=alt_src_bucket_name, Key=name, Body=randcontent()) + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + for key in keys: + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, " ", 'REST.PUT.OBJECT', src_keys, "Standard", num_keys*2) + + # non wildcard source account policy + policy['Statement'][0]['Condition']['StringLike']['aws:SourceAccount'] = "{}${}".format(src_tenant, src_user) if src_tenant else src_user + _set_bucket_policy(client, log_bucket_name, policy) + for j in range(num_keys): + name = 'myobject_2_'+str(j) + src_keys.append(name) + alt_client.put_object(Bucket=alt_src_bucket_name, Key=name, Body=randcontent()) + try: + _flush_logs(alt_client, alt_src_bucket_name) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'AccessDenied' + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + # no new keys were added + assert len(keys) == 1 + + +@pytest.mark.bucket_logging +def test_put_bucket_logging_policy_wildcard(): + _put_bucket_logging_policy_wildcard(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_put_bucket_logging_policy_wildcard_objects(): + _put_bucket_logging_policy_wildcard(True) + + +def _bucket_logging_permission_change(logging_type): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + log_tenant = "" + src_tenant = "" + src_user = get_main_user_id() + + policy = { + "Version": "2012-10-17", + "Statement": [{ + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": {"Service": "logging.s3.amazonaws.com"}, + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3::{}:{}/{}".format(log_tenant, log_bucket_name, prefix), + "Condition": { + "ArnLike": {"aws:SourceArn": "arn:aws:s3::{}:{}".format(src_tenant, src_bucket_name)}, + "StringEquals": { + "aws:SourceAccount": "{}${}".format(src_tenant, src_user) if src_tenant else src_user + } + } + }] + } + + policy_document = json.dumps(policy) + response = client.put_bucket_policy(Bucket=log_bucket_name, Policy=policy_document) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + if logging_type == 'Journal': + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix, 'LoggingType': logging_type}}) + else: + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix}}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + # put objects when policy is ok + num_keys = 5 + good_src_keys = [] + for j in range(num_keys): + name = 'myobject'+str(j) + good_src_keys.append(name) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + # change policy to invalid source bucket + policy['Statement'][0]['Condition']['ArnLike']['aws:SourceArn'] = "arn:aws:s3::{}:{}".format(src_tenant, "kaboom") + _set_bucket_policy(client, log_bucket_name, policy) + + bad_src_keys = [] + # put objects when policy should reject them + for j in range(num_keys, num_keys*2): + name = 'myobject'+str(j) + bad_src_keys.append(name) + if logging_type == 'Standard': + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + else: + try: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'AccessDenied' + assert e.response['Error']['Message'].startswith('Logging bucket') + + try: + _flush_logs(client, src_bucket_name) + assert False, 'expected failure' + except ClientError as e: + assert e.response['Error']['Code'] == 'AccessDenied' + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_permission_change_s(): + _bucket_logging_permission_change('Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_permission_change_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_permission_change('Journal') + + +def _bucket_logging_objects(src_client, src_bucket_name, log_client, log_bucket_name, log_type, op_name): + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + src_client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + expected_count = num_keys + + response = src_client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + + flushed_obj = _flush_logs(src_client, src_bucket_name) + + response = log_client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + for key in keys: + if flushed_obj is not None: + assert key == flushed_obj + assert key.startswith('log/') + response = log_client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, op_name, src_keys, log_type, expected_count) + + +def _put_bucket_logging_tenant(log_type): + # src is on default tenant and log is on a different tenant + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + tenant_client = get_tenant_client() + log_bucket = get_new_bucket(client=tenant_client, name=log_bucket_name) + client = get_client() + prefix = 'log/' + log_tenant = get_tenant_name() + _set_log_bucket_policy_tenant(tenant_client, log_tenant, log_bucket_name, "", get_main_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_tenant+':'+log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, tenant_client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + # src is on default tenant and log is on a different tenant with the same name + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = src_bucket_name + tenant_client = get_tenant_client() + log_bucket = get_new_bucket(client=tenant_client, name=log_bucket_name) + client = get_client() + tenant_name = get_tenant_name() + _set_log_bucket_policy_tenant(tenant_client, tenant_name, log_bucket_name, "", get_main_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': tenant_name+':'+log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, tenant_client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + + try: + # src is on default tenant and log is on a different tenant + # log bucket name not set correctly + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_client = get_tenant_client() + log_bucket = get_new_bucket(client=log_client, name=log_bucket_name) + client = get_client() + _set_log_bucket_policy_tenant(log_client, get_tenant_name(), log_bucket_name, "", get_main_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + except ClientError as e: + assert e.response['Error']['Code'] == 'NoSuchKey' + else: + assert False, 'expected failure' + + # src and log are on the same tenant + # log bucket name is set with tenant + client = get_tenant_client() + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket(client=client, name=log_bucket_name) + tenant_name = get_tenant_name() + _set_log_bucket_policy_tenant(client, tenant_name, log_bucket_name, tenant_name, get_tenant_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': tenant_name+':'+log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + # src and log are on the same tenant + # log bucket name is set without tenant + client = get_tenant_client() + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket(client=client, name=log_bucket_name) + tenant_name = get_tenant_name() + _set_log_bucket_policy_tenant(client, tenant_name, log_bucket_name, tenant_name, get_tenant_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + # src is on tenant and log is on the default tenant + # log bucket name is set with explicit default tenant + client = get_tenant_client() + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_client = get_client() + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket(client=log_client, name=log_bucket_name) + _set_log_bucket_policy_tenant(log_client, "", log_bucket_name, get_tenant_name(), get_tenant_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': ':'+log_bucket_name, 'TargetPrefix': 'log/'} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, log_client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + try: + # src is on tenant and log is on the default tenant + client = get_tenant_client() + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_client = get_client() + log_bucket = get_new_bucket(client=log_client, name=log_bucket_name) + _set_log_bucket_policy_tenant(log_client, "", log_bucket_name, get_tenant_name(), get_tenant_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': 'log/'} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + except ClientError as e: + assert e.response['Error']['Code'] == 'NoSuchKey' + else: + assert False, 'expected failure' + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_put_bucket_logging_tenant_s(): + _put_bucket_logging_tenant('Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_put_bucket_logging_tenant_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _put_bucket_logging_tenant('Journal') + + +def _put_bucket_logging_account(log_type): + # src is default user and log is in an account user + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_client = get_iam_root_s3client() + log_bucket = get_new_bucket(client=log_client, name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy_tenant(log_client, "", log_bucket_name, "", get_main_user_id(), [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, log_client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + # src and log are in an iam account + client = get_iam_root_s3client() + iam_client = get_iam_root_client() + iam_user = iam_client.get_user()['User']['Arn'].split(':')[-2] # Get the IAM user name from ARN + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket(client=client, name=log_bucket_name) + _set_log_bucket_policy_tenant(client, "", log_bucket_name, "", iam_user, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + # src is in an iam account and log is the default user + client = get_iam_root_s3client() + iam_client = get_iam_root_client() + iam_user = iam_client.get_user()['User']['Arn'].split(':')[-2] # Get the IAM user name from ARN + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket(client=client, name=src_bucket_name) + log_client = get_client() + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket(client=log_client, name=log_bucket_name) + _set_log_bucket_policy_tenant(log_client, "", log_bucket_name, "", iam_user, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': 'log/'} + if log_type == 'Journal': + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + _bucket_logging_objects(client, src_bucket_name, log_client, log_bucket_name, log_type, 'REST.PUT.OBJECT') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_put_bucket_logging_account_s(): + _put_bucket_logging_account('Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_put_bucket_logging_account_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _put_bucket_logging_account('Journal') + + +@pytest.mark.bucket_logging +def test_rm_bucket_logging(): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={}) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + assert not 'LoggingEnabled' in response + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_put_bucket_logging_extensions(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix, + 'LoggingType': 'Standard', + 'ObjectRollTime': expected_object_roll_time, + 'RecordsBatchSize': 0 + } + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.get_bucket_logging(Bucket=src_bucket_name) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + logging_enabled['TargetObjectKeyFormat'] = {'SimplePrefix': {}} + assert response['LoggingEnabled'] == logging_enabled + + +def _bucket_logging_put_objects(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + if versioned: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + if versioned: + expected_count = 2*num_keys + else: + expected_count = num_keys + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + + flushed_obj = _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + record_type = 'Standard' if not has_extensions else 'Journal' + + for key in keys: + if flushed_obj is not None: + assert key == flushed_obj + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, record_type, expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_put_objects(): + _bucket_logging_put_objects(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_put_objects_versioned(): + _bucket_logging_put_objects(True) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_put_concurrency(): + src_bucket_name = get_new_bucket() + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client(client_config=botocore.config.Config(max_pool_connections=50)) + has_extensions = _has_bucket_logging_extension() + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 50 + t = [] + for i in range(num_keys): + name = 'myobject'+str(i) + thr = threading.Thread(target = client.put_object, + kwargs={'Bucket': src_bucket_name, 'Key': name, 'Body': randcontent()}) + thr.start() + t.append(thr) + _do_wait_completion(t) + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + if not has_extensions: + time.sleep(expected_object_roll_time*1.1) + t = [] + for i in range(num_keys): + if has_extensions: + thr = threading.Thread(target = client.post_bucket_logging, + kwargs={'Bucket': src_bucket_name}) + else: + thr = threading.Thread(target = client.put_object, + kwargs={'Bucket': src_bucket_name, 'Key': 'dummy', 'Body': 'dummy'}) + thr.start() + t.append(thr) + _do_wait_completion(t) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + + record_type = 'Standard' if not has_extensions else 'Journal' + + body = "" + for key in keys: + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, record_type, num_keys) + + +def _bucket_logging_delete_objects(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + if versioned: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + for key in src_keys: + if versioned: + response = client.head_object(Bucket=src_bucket_name, Key=key) + client.delete_object(Bucket=src_bucket_name, Key=key, VersionId=response['VersionId']) + client.delete_object(Bucket=src_bucket_name, Key=key) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + if versioned: + expected_count = 2*num_keys + else: + expected_count = num_keys + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + record_type = 'Standard' if not has_extensions else 'Journal' + assert _verify_records(body, src_bucket_name, 'REST.DELETE.OBJECT', src_keys, record_type, expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_delete_objects(): + _bucket_logging_delete_objects(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_delete_objects_versioned(): + _bucket_logging_delete_objects(True) + + +@pytest.mark.bucket_logging +def _bucket_logging_get_objects(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + if versioned: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Standard' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + for key in src_keys: + if versioned: + response = client.head_object(Bucket=src_bucket_name, Key=key) + client.get_object(Bucket=src_bucket_name, Key=key, VersionId=response['VersionId']) + client.get_object(Bucket=src_bucket_name, Key=key) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + if versioned: + expected_count = 2*num_keys + else: + expected_count = num_keys + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.GET.OBJECT', src_keys, 'Standard', expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_get_objects(): + _bucket_logging_get_objects(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_get_objects_versioned(): + _bucket_logging_get_objects(True) + + +@pytest.mark.bucket_logging +def _bucket_logging_copy_objects(versioned, another_bucket): + src_bucket_name = get_new_bucket() + if another_bucket: + dst_bucket_name = get_new_bucket() + else: + dst_bucket_name = src_bucket_name + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + if versioned: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name, dst_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + if another_bucket: + response = client.put_bucket_logging(Bucket=dst_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + dst_keys = [] + for key in src_keys: + dst_keys.append('copy_of_'+key) + if another_bucket: + client.copy_object(Bucket=dst_bucket_name, Key='copy_of_'+key, CopySource={'Bucket': src_bucket_name, 'Key': key}) + else: + client.copy_object(Bucket=src_bucket_name, Key='copy_of_'+key, CopySource={'Bucket': src_bucket_name, 'Key': key}) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + record_type = 'Standard' if not has_extensions else 'Journal' + assert _verify_records(body, dst_bucket_name, 'REST.PUT.OBJECT', dst_keys, record_type, num_keys) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_copy_objects(): + _bucket_logging_copy_objects(False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_copy_objects_versioned(): + _bucket_logging_copy_objects(True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_copy_objects_bucket(): + _bucket_logging_copy_objects(False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_copy_objects_bucket_versioned(): + _bucket_logging_copy_objects(True, True) + + +@pytest.mark.bucket_logging +def _bucket_logging_head_objects(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Standard' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + for key in src_keys: + if versioned: + response = client.head_object(Bucket=src_bucket_name, Key=key) + client.head_object(Bucket=src_bucket_name, Key=key, VersionId=response['VersionId']) + else: + client.head_object(Bucket=src_bucket_name, Key=key) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + if versioned: + expected_count = 2*num_keys + else: + expected_count = num_keys + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.HEAD.OBJECT', src_keys, 'Standard', expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_head_objects(): + _bucket_logging_head_objects(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_head_objects_versioned(): + _bucket_logging_head_objects(True) + + +@pytest.mark.bucket_logging +def _bucket_logging_mpu(versioned, record_type): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if record_type == 'Journal': + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + src_key = "myobject" + objlen = 30 * 1024 * 1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=src_bucket_name, key=src_key, size=objlen) + client.complete_multipart_upload(Bucket=src_bucket_name, Key=src_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + if versioned: + (upload_id, data, parts) = _multipart_upload(bucket_name=src_bucket_name, key=src_key, size=objlen) + client.complete_multipart_upload(Bucket=src_bucket_name, Key=src_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + if versioned: + expected_count = 4 if not record_type == 'Journal' else 2 + else: + expected_count = 2 if not record_type == 'Journal' else 1 + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.POST.UPLOAD', [src_key, src_key], record_type, expected_count) + if record_type == 'Standard': + if versioned: + expected_count = 12 + else: + expected_count = 6 + assert _verify_records(body, src_bucket_name, 'REST.PUT.PART', [src_key, src_key], record_type, expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_mpu_s(): + _bucket_logging_mpu(False, 'Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_mpu_versioned_s(): + _bucket_logging_mpu(True, 'Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_mpu_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_mpu(False, 'Journal') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_mpu_versioned_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_mpu(True, 'Journal') + +@pytest.mark.bucket_logging +def _bucket_logging_mpu_copy(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + src_key = "myobject" + objlen = 30 * 1024 * 1024 + (upload_id, data, parts) = _multipart_upload(bucket_name=src_bucket_name, key=src_key, size=objlen) + client.complete_multipart_upload(Bucket=src_bucket_name, Key=src_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + if versioned: + (upload_id, data, parts) = _multipart_upload(bucket_name=src_bucket_name, key=src_key, size=objlen) + client.complete_multipart_upload(Bucket=src_bucket_name, Key=src_key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + client.copy_object(Bucket=src_bucket_name, Key='copy_of_'+src_key, CopySource={'Bucket': src_bucket_name, 'Key': src_key}) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + record_type = 'Standard' if not has_extensions else 'Journal' + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', ['copy_of_'+src_key], record_type, 1) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_mpu_copy(): + _bucket_logging_mpu_copy(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_mpu_copy_versioned(): + _bucket_logging_mpu_copy(True) + + +def _bucket_logging_multi_delete(versioned): + src_bucket_name = get_new_bucket() + if versioned: + check_configure_versioning_retry(src_bucket_name, "Enabled", "Enabled") + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + if versioned: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + logging_enabled['LoggingType'] = 'Journal' + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + if versioned: + response = client.list_object_versions(Bucket=src_bucket_name) + objs_list = [] + for version in response['Versions']: + obj_dict = {'Key': version['Key'], 'VersionId': version['VersionId']} + objs_list.append(obj_dict) + objs_dict = {'Objects': objs_list} + client.delete_objects(Bucket=src_bucket_name, Delete=objs_dict) + else: + objs_dict = _make_objs_dict(key_names=src_keys) + client.delete_objects(Bucket=src_bucket_name, Delete=objs_dict) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + if versioned: + expected_count = 2*num_keys + else: + expected_count = num_keys + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + record_type = 'Standard' if not has_extensions else 'Journal' + assert _verify_records(body, src_bucket_name, "REST.POST.DELETE_MULTI_OBJECT", src_keys, record_type, expected_count) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_multi_delete(): + _bucket_logging_multi_delete(False) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_multi_delete_versioned(): + _bucket_logging_multi_delete(True) + + +def _bucket_logging_type(logging_type): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled = { + 'TargetBucket': log_bucket_name, + 'TargetPrefix': prefix, + 'ObjectRollTime': expected_object_roll_time, + 'LoggingType': logging_type + } + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + client.head_object(Bucket=src_bucket_name, Key=name) + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + if logging_type == 'Journal': + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Journal', num_keys) + assert _verify_records(body, src_bucket_name, 'REST.HEAD.OBJECT', src_keys, 'Journal', num_keys) == False + elif logging_type == 'Standard': + assert _verify_records(body, src_bucket_name, 'REST.HEAD.OBJECT', src_keys, 'Standard', num_keys) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Standard', num_keys) + else: + assert False, 'invalid logging type:'+logging_type + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_event_type_j(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_type('Journal') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_event_type_s(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + _bucket_logging_type('Standard') + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_roll_time(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + roll_time = 10 + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix, 'ObjectRollTime': roll_time} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 5 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + + time.sleep(roll_time/2) + client.put_object(Bucket=src_bucket_name, Key='myobject', Body=randcontent()) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + + time.sleep(roll_time/2) + client.put_object(Bucket=src_bucket_name, Key='myobject', Body=randcontent()) + + # It can take up to 10s for the bucket logging manager to detect a new commit list + time.sleep(11) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + key = keys[0] + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Standard', num_keys) + client.delete_object(Bucket=log_bucket_name, Key=key) + + num_keys = 25 + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + time.sleep(1) + + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + + time.sleep(roll_time) + client.put_object(Bucket=src_bucket_name, Key='myobject', Body=randcontent()) + time.sleep(5) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) > 1 + + body = '' + for key in keys: + assert key.startswith('log/') + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Standard', num_keys+1) + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_multiple_prefixes(): + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_buckets = 5 + log_prefixes = [] + buckets = [] + bucket_name_prefix = get_new_bucket_name() + for j in range(num_buckets): + src_bucket_name = bucket_name_prefix+str(j) + src_bucket = get_new_bucket_resource(name=src_bucket_name) + log_prefixes.append(src_bucket_name+'/') + buckets.append(src_bucket_name) + + _set_log_bucket_policy(client, log_bucket_name, buckets, log_prefixes) + + for j in range(num_buckets): + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': log_prefixes[j]} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=buckets[j], BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 5 + for src_bucket_name in buckets: + for j in range(num_keys): + name = 'myobject'+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + for src_bucket_name in buckets: + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) >= num_buckets + + for key in keys: + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + found = False + for src_bucket_name in buckets: + if key.startswith(src_bucket_name): + found = True + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Standard', num_keys) + assert found + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +@pytest.mark.fails_without_logging_rollover +def test_bucket_logging_single_prefix(): + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + has_extensions = _has_bucket_logging_extension() + + num_buckets = 5 + buckets = [] + prefix = 'log/' + bucket_name_prefix = get_new_bucket_name() + for j in range(num_buckets): + src_bucket_name = bucket_name_prefix+str(j) + src_bucket = get_new_bucket_resource(name=src_bucket_name) + buckets.append(src_bucket_name) + + _set_log_bucket_policy(client, log_bucket_name, buckets, [prefix]) + + for j in range(num_buckets): + # minimal configuration + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': 'log/'} + if has_extensions: + logging_enabled['ObjectRollTime'] = expected_object_roll_time + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 5 + bucket_ind = 0 + for src_bucket_name in buckets: + bucket_ind += 1 + for j in range(num_keys): + name = 'myobject'+str(bucket_ind)+str(j) + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + _flush_logs(client, src_bucket_name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + + key = keys[0] + response = client.get_object(Bucket=log_bucket_name, Key=key) + body = _get_body(response) + found = False + for src_bucket_name in buckets: + response = client.list_objects_v2(Bucket=src_bucket_name) + src_keys = _get_keys(response) + found = _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_keys, 'Standard', num_keys) + assert found + + +@pytest.mark.bucket_logging +@pytest.mark.fails_on_aws +def test_bucket_logging_object_meta(): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + client = get_client() + src_bucket_name = get_new_bucket_name() + src_bucket = client.create_bucket(Bucket=src_bucket_name, ObjectLockEnabledForBucket=True) + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + + prefix = 'log/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled = {'TargetBucket': log_bucket_name, 'TargetPrefix': prefix, 'LoggingType': 'Journal'} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + name = 'myobject' + response = client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + version_id = response['VersionId'] + # PutObjectAcl + client.put_object_acl(ACL='public-read-write', Bucket=src_bucket_name, Key=name, VersionId=version_id) + # PutObjectTagging + client.put_object_tagging(Bucket=src_bucket_name, Key=name, VersionId=version_id, + Tagging={'TagSet': [{'Key': 'tag1', 'Value': 'value1'}, {'Key': 'tag2', 'Value': 'value2'}]}) + # DeleteObjectTagging + client.delete_object_tagging(Bucket=src_bucket_name, Key=name, VersionId=version_id) + # PutObjectLegalHold + client.put_object_legal_hold(Bucket=src_bucket_name, Key=name, LegalHold={'Status': 'ON'}) + # PutObjectRetention + client.put_object_retention(Bucket=src_bucket_name, Key=name, Retention={'Mode': 'GOVERNANCE', 'RetainUntilDate': datetime.datetime.now() + datetime.timedelta(days=60)}) + + _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + log_keys = _get_keys(response) + assert len(log_keys) == 1 + log_key = log_keys[0] + expected_op_list = ['REST.PUT.OBJECT', 'REST.PUT.ACL', 'REST.PUT.LEGAL_HOLD', 'REST.PUT.RETENTION', 'REST.PUT.OBJECT_TAGGING', 'REST.DELETE.OBJECT_TAGGING'] + op_list = [] + response = client.get_object(Bucket=log_bucket_name, Key=log_key) + body = _get_body(response) + for record in iter(body.splitlines()): + parsed_record = _parse_log_record(record, 'Journal') + logger.info('bucket log record: %s', json.dumps(parsed_record, indent=4)) + op_list.append(parsed_record['Operation']) + version = parsed_record['VersionID'] + if version != '-': + assert version == version_id + + assert sorted(expected_op_list) == sorted(op_list) + + # allow cleanup + client.put_object_legal_hold(Bucket=src_bucket_name, Key=name, LegalHold={'Status': 'OFF'}) + client.delete_object(Bucket=src_bucket_name, Key=name, VersionId=version_id, BypassGovernanceRetention=True) + +def _verify_flushed_on_put(result): + if _has_bucket_logging_extension(): + assert result['ResponseMetadata']['HTTPStatusCode'] == 200 + return result['FlushedLoggingObject'] + + +def _bucket_logging_cleanup(cleanup_type, logging_type, single_prefix, concurrency): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + num_buckets = 5 + buckets = [] + log_prefixes = [] + longer_time = expected_object_roll_time*10 + for j in range(num_buckets): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + buckets.append(src_bucket_name) + if single_prefix: + log_prefixes.append('log/') + else: + log_prefixes.append(src_bucket_name+'/') + + _set_log_bucket_policy(client, log_bucket_name, buckets, log_prefixes) + + for j in range(num_buckets): + logging_enabled = {'TargetBucket': log_bucket_name, + 'ObjectRollTime': longer_time, + 'LoggingType': logging_type, + 'TargetPrefix': log_prefixes[j]} + response = client.put_bucket_logging(Bucket=buckets[j], BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 10 + src_names = [] + for j in range(num_keys): + src_names.append('myobject'+str(j)) + + for src_bucket_name in buckets: + for name in src_names: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + client.delete_object(Bucket=src_bucket_name, Key=name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + + t = [] + flushed_obj = None + updated_longer_time = expected_object_roll_time*20 + first_time = True + for src_bucket_name in buckets: + if not single_prefix: + logging_enabled['TargetPrefix'] = src_bucket_name+'/' + if cleanup_type == 'deletion': + # cleanup based on bucket deletion + if concurrency: + thr = threading.Thread(target = client.delete_bucket, + kwargs={'Bucket': src_bucket_name}) + thr.start() + t.append(thr) + else: + client.delete_bucket(Bucket=src_bucket_name) + elif cleanup_type == 'disabling': + # cleanup based on disabling bucket logging + if concurrency: + thr = threading.Thread(target = client.put_bucket_logging, + kwargs={'Bucket': src_bucket_name, 'BucketLoggingStatus': {}}) + thr.start() + t.append(thr) + else: + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={}) + if first_time: + flushed_obj = _verify_flushed_on_put(result) + if single_prefix: + first_time = False + elif cleanup_type == 'updating': + # cleanup based on updating bucket logging parameters + logging_enabled['ObjectRollTime'] = updated_longer_time + if concurrency: + thr = threading.Thread(target = client.put_bucket_logging, + kwargs={'Bucket': src_bucket_name, 'BucketLoggingStatus': {'LoggingEnabled': logging_enabled}}) + thr.start() + t.append(thr) + else: + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + flushed_obj = _verify_flushed_on_put(result) + elif cleanup_type == 'notupdating': + # no concurrecy testing + client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + elif cleanup_type != 'target': + assert False, 'invalid cleanup type: ' + cleanup_type + + _do_wait_completion(t) + + if cleanup_type == 'target': + # delete the log bucket and then create it to make sure that no pending objects remained + # no concurrecy testing + client.delete_bucket(Bucket=log_bucket_name) + log_bucket = get_new_bucket_resource(name=log_bucket_name) + _set_log_bucket_policy(client, log_bucket_name, buckets, log_prefixes) + old_names = src_names + src_names = [] + for j in range(num_keys): + src_names.append('after_deletion_myobject'+str(j)) + for src_bucket_name in buckets: + for name in src_names: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + client.delete_object(Bucket=src_bucket_name, Key=name) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + # flsuh any new pending objects + for src_bucket_name in buckets: + client.post_bucket_logging(Bucket=src_bucket_name) + # make sure that only the new objects are logged + exact_match = True + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + + if flushed_obj: + assert flushed_obj in keys + + if cleanup_type == 'notupdating': + # no cleanup expected + assert len(keys) == 0 + return + + if concurrency: + assert len(keys) >= 1 and len(keys) <= num_buckets + else: + if single_prefix and logging_type == 'Journal' and cleanup_type not in ['target', 'updating']: + assert len(keys) == 1 + else: + assert len(keys) == num_buckets + + prefixes = [] + for src_bucket_name in buckets: + if single_prefix: + prefix = 'log/' + else: + prefix = src_bucket_name+'/' + prefixes.append(prefix) + + body = "" + for key in keys: + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + found = False + for prefix in prefixes: + if key.startswith(prefix): + found = True + assert found, 'log key does not match any expected prefix: ' + key + ' expected prefixes: ' + str(prefixes) + + exact_match = True + for src_bucket_name in buckets: + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_names, logging_type, num_keys, exact_match) + assert _verify_records(body, src_bucket_name, 'REST.DELETE.OBJECT', src_names, logging_type, num_keys, exact_match) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_deletion_j(): + _bucket_logging_cleanup('deletion', 'Journal', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_deletion_j_single(): + _bucket_logging_cleanup('deletion', 'Journal', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_disabling_j(): + _bucket_logging_cleanup('disabling', 'Journal', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_disabling_j_single(): + _bucket_logging_cleanup('disabling', 'Journal', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_updating_j(): + _bucket_logging_cleanup('updating', 'Journal', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_updating_j_single(): + _bucket_logging_cleanup('updating', 'Journal', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_notupdating_j(): + _bucket_logging_cleanup('notupdating', 'Journal', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_notupdating_j_single(): + _bucket_logging_cleanup('notupdating', 'Journal', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_deletion_s(): + _bucket_logging_cleanup('deletion', 'Standard', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_deletion_s_single(): + _bucket_logging_cleanup('deletion', 'Standard', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_disabling_s(): + _bucket_logging_cleanup('disabling', 'Standard', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_disabling_s_single(): + _bucket_logging_cleanup('disabling', 'Standard', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_updating_s(): + _bucket_logging_cleanup('updating', 'Standard', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_updating_s_single(): + _bucket_logging_cleanup('updating', 'Standard', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_notupdating_s(): + _bucket_logging_cleanup('notupdating', 'Standard', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_notupdating_s_single(): + _bucket_logging_cleanup('notupdating', 'Standard', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_target_cleanup_j(): + _bucket_logging_cleanup('target', 'Journal', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_target_cleanup_j_single(): + _bucket_logging_cleanup('target', 'Journal', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_target_cleanup_s(): + _bucket_logging_cleanup('target', 'Standard', False, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_target_cleanup_s_single(): + _bucket_logging_cleanup('target', 'Standard', True, False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_concurrent_deletion_j(): + _bucket_logging_cleanup('deletion', 'Journal', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_concurrent_deletion_j_single(): + _bucket_logging_cleanup('deletion', 'Journal', True, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_disabling_j(): + _bucket_logging_cleanup('disabling', 'Journal', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_disabling_j_single(): + _bucket_logging_cleanup('disabling', 'Journal', True, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_updating_j(): + _bucket_logging_cleanup('updating', 'Journal', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_updating_j_single(): + _bucket_logging_cleanup('updating', 'Journal', True, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_concurrent_deletion_s(): + _bucket_logging_cleanup('deletion', 'Standard', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_bucket_concurrent_deletion_s_single(): + _bucket_logging_cleanup('deletion', 'Standard', True, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_disabling_s(): + _bucket_logging_cleanup('disabling', 'Standard', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_disabling_s_single(): + _bucket_logging_cleanup('disabling', 'Standard', True, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_updating_s(): + _bucket_logging_cleanup('updating', 'Standard', False, True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_cleanup_concurrent_updating_s_single(): + _bucket_logging_cleanup('updating', 'Standard', True, True) + + +def _bucket_logging_partial_cleanup(cleanup_type, logging_type, concurrency): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + num_buckets = 3 + buckets = [] + log_prefixes = [] + longer_time = expected_object_roll_time*10 + for j in range(num_buckets): + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + buckets.append(src_bucket_name) + log_prefixes.append('log/') + + _set_log_bucket_policy(client, log_bucket_name, buckets, log_prefixes) + + for j in range(num_buckets): + logging_enabled = {'TargetBucket': log_bucket_name, + 'ObjectRollTime': longer_time, + 'LoggingType': logging_type, + 'TargetPrefix': log_prefixes[j]} + response = client.put_bucket_logging(Bucket=buckets[j], BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 3 + src_names = [] + for j in range(num_keys): + src_names.append('myobject'+str(j)) + + for src_bucket_name in buckets: + for name in src_names: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + client.delete_object(Bucket=src_bucket_name, Key=name) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 0 + + t = [] + flushed_obj = None + updated_longer_time = expected_object_roll_time*20 + first_time = True + for j in range(num_buckets-1): + src_bucket_name = buckets[j] + if cleanup_type == 'deletion': + # cleanup based on bucket deletion + if concurrency: + thr = threading.Thread(target = client.delete_bucket, + kwargs={'Bucket': src_bucket_name}) + thr.start() + t.append(thr) + else: + client.delete_bucket(Bucket=src_bucket_name) + elif cleanup_type == 'disabling': + # cleanup based on disabling bucket logging + if concurrency: + thr = threading.Thread(target = client.put_bucket_logging, + kwargs={'Bucket': src_bucket_name, 'BucketLoggingStatus': {}}) + thr.start() + t.append(thr) + else: + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={}) + if first_time: + flushed_obj = _verify_flushed_on_put(result) + first_time = False + elif cleanup_type == 'updating': + # cleanup based on updating bucket logging parameters + logging_enabled['ObjectRollTime'] = updated_longer_time + if concurrency: + thr = threading.Thread(target = client.put_bucket_logging, + kwargs={'Bucket': src_bucket_name, 'BucketLoggingStatus': {'LoggingEnabled': logging_enabled}}) + thr.start() + t.append(thr) + else: + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + flushed_obj = _verify_flushed_on_put(result) + else: + assert False, 'invalid cleanup type: ' + cleanup_type + + last_bucket_name = buckets[num_buckets-1] + for name in src_names: + client.put_object(Bucket=last_bucket_name, Key=name, Body=randcontent()) + client.delete_object(Bucket=last_bucket_name, Key=name) + + _do_wait_completion(t) + + _flush_logs(client, last_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + + if flushed_obj: + assert flushed_obj in keys + + if concurrency: + assert len(keys) >= 1 and len(keys) <= num_buckets + elif cleanup_type == 'updating': + assert len(keys) == num_buckets + elif logging_type == 'Standard': + # 1st deleted/disabled source write the bucket_deletion/put_bucket_logging record + assert len(keys) == num_buckets + else: + assert len(keys) == num_buckets - 1 + + + prefixes = [] + for src_bucket_name in buckets: + prefix = 'log/' + prefixes.append(prefix) + + body = "" + for key in keys: + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + found = False + for prefix in prefixes: + if key.startswith(prefix): + found = True + assert found, 'log key does not match any expected prefix: ' + key + ' expected prefixes: ' + str(prefixes) + + exact_match = True + for src_bucket_name in buckets: + _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_names, logging_type, num_keys, exact_match) + _verify_records(body, src_bucket_name, 'REST.DELETE.OBJECT', src_names, logging_type, num_keys, exact_match) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_updating_s(): + _bucket_logging_partial_cleanup('updating', 'Standard', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_disabling_s(): + _bucket_logging_partial_cleanup('disabling', 'Standard', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_deletion_s(): + _bucket_logging_partial_cleanup('deletion', 'Standard', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_updating_j(): + _bucket_logging_partial_cleanup('updating', 'Journal', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_disabling_j(): + _bucket_logging_partial_cleanup('disabling', 'Journal', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_concurrent_deletion_j(): + _bucket_logging_partial_cleanup('deletion', 'Journal', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_updating_s(): + _bucket_logging_partial_cleanup('updating', 'Standard', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_disabling_s(): + _bucket_logging_partial_cleanup('disabling', 'Standard', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_deletion_s(): + _bucket_logging_partial_cleanup('deletion', 'Standard', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_updating_j(): + _bucket_logging_partial_cleanup('updating', 'Journal', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_disabling_j(): + _bucket_logging_partial_cleanup('disabling', 'Journal', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_part_cleanup_deletion_j(): + _bucket_logging_partial_cleanup('deletion', 'Journal', False) + + +def _bucket_logging_conf_update(logging_type, update_value, concurrency): + if not _has_bucket_logging_extension(): + pytest.skip('ceph extension to bucket logging not supported at client') + log_bucket_name = get_new_bucket_name() + log_bucket = get_new_bucket_resource(name=log_bucket_name) + client = get_client() + + prefix = 'log/' + longer_time = expected_object_roll_time*10 + src_bucket_name = get_new_bucket_name() + src_bucket = get_new_bucket_resource(name=src_bucket_name) + + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + + logging_enabled = {'TargetBucket': log_bucket_name, + 'ObjectRollTime': longer_time, + 'LoggingType': logging_type, + 'TargetPrefix': prefix} + response = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + + # conf update without any log records + if update_value == "roll_time": + logging_enabled['ObjectRollTime'] = longer_time*2 + elif update_value == "prefix": + prefix = 'newlog1/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled['TargetPrefix'] = prefix + else: + assert False, 'invalid update value: ' + update_value + + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + num_keys = 100 + src_names = [] + for j in range(num_keys): + src_names.append('myobject'+str(j)) + + def put_object_with_exception_handling(bucket, key, body): + try: + client.put_object(Bucket=bucket, Key=key, Body=body) + except Exception as e: + logger.warning(f'put_object failed for {key}: {e}') + + t = [] + for name in src_names: + if concurrency: + thr = threading.Thread(target=put_object_with_exception_handling, + args=(src_bucket_name, name, randcontent())) + thr.start() + t.append(thr) + else: + try: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + except Exception as e: + logger.warning(f'put_object failed for {name}: {e}') + + if update_value == "roll_time": + logging_enabled['ObjectRollTime'] = longer_time*3 + elif update_value == "prefix": + prefix = 'newlog2/' + _set_log_bucket_policy(client, log_bucket_name, [src_bucket_name], [prefix]) + logging_enabled['TargetPrefix'] = prefix + else: + assert False, 'invalid update value: ' + update_value + result = client.put_bucket_logging(Bucket=src_bucket_name, BucketLoggingStatus={ + 'LoggingEnabled': logging_enabled, + }) + + flushed_obj = _verify_flushed_on_put(result) + logger.info('flushed log object after conf update: %s', flushed_obj) + + # perform conf update while puts are ongoing + _do_wait_completion(t) + + expected_count = num_keys + expected_log_objs = 2 + if concurrency: + # some records may be written after the conf change + _flush_logs(client, src_bucket_name) + expected_log_objs = 3 + if update_value == 'prefix' and logging_type == 'Journal': + # when changing prefix in concurrency mode + # some put operations may fail, so the list of source objects + # needs to be taken fro mthe bucket + response = client.list_objects_v2(Bucket=src_bucket_name) + src_names = _get_keys(response) + expected_count = len(src_names) + + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + # one empty log object due to the initial conf change + # and one with the log records due to the 2nd conf change + # and one flushed object at the end + assert len(keys) == expected_log_objs + assert flushed_obj in keys + + exact_match = True + body = "" + for key in keys: + logger.info('processing log object: %s', key) + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + # delete the key that we already processed + client.delete_object(Bucket=log_bucket_name, Key=key) + + ok = _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_names, logging_type, expected_count, exact_match) + if not (concurrency and update_value == 'prefix' and logging_type == 'Standard'): + # we can have silent failures when changing the prefix in concurrency mode with standard logging + assert ok + + # make sure that logging still works after conf change + src_names = [] + for j in range(num_keys): + src_names.append('anotherobject'+str(j)) + for name in src_names: + client.put_object(Bucket=src_bucket_name, Key=name, Body=randcontent()) + + flushed_obj = _flush_logs(client, src_bucket_name) + response = client.list_objects_v2(Bucket=log_bucket_name) + keys = _get_keys(response) + assert len(keys) == 1 + assert flushed_obj in keys + + body = "" + for key in keys: + response = client.get_object(Bucket=log_bucket_name, Key=key) + body += _get_body(response) + + assert _verify_records(body, src_bucket_name, 'REST.PUT.OBJECT', src_names, logging_type, num_keys, exact_match) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_updating_roll_s(): + _bucket_logging_conf_update('Standard', 'roll_time', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_updating_roll_j(): + _bucket_logging_conf_update('Journal', 'roll_time', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_updating_pfx_s(): + _bucket_logging_conf_update('Standard', 'prefix', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_updating_pfx_j(): + _bucket_logging_conf_update('Journal', 'prefix', False) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_concurrent_updating_roll_s(): + _bucket_logging_conf_update('Standard', 'roll_time', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_concurrent_updating_roll_j(): + _bucket_logging_conf_update('Journal', 'roll_time', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_concurrent_updating_pfx_s(): + _bucket_logging_conf_update('Standard', 'prefix', True) + + +@pytest.mark.bucket_logging +@pytest.mark.bucket_logging_cleanup +@pytest.mark.fails_on_aws +def test_bucket_logging_conf_concurrent_updating_pfx_j(): + _bucket_logging_conf_update('Journal', 'prefix', True) + + +def check_parts_count(parts, expected): + # AWS docs disagree on the name of this element + if 'TotalPartsCount' in parts: + assert parts['TotalPartsCount'] == expected + else: + assert parts['PartsCount'] == expected + +@pytest.mark.checksum +@pytest.mark.fails_on_dbstore +def test_get_multipart_checksum_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + + #pdb.set_trace() + key = "multipart_checksum" + key_metadata = {'foo': 'bar'} + content_type = 'text/plain' + objlen = 64 * 1024 * 1024 + + (upload_id, data, parts, checksums) = \ + _multipart_upload_checksum(bucket_name=bucket_name, key=key, size=objlen, + content_type=content_type, metadata=key_metadata) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, + UploadId=upload_id, + MultipartUpload={'Parts': parts}) + upload_checksum = response['ChecksumSHA256'] + + response = client.get_object(Bucket=bucket_name, Key=key) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + + response = client.get_object_attributes(Bucket=bucket_name, Key=key, \ + ObjectAttributes=request_attributes) + + # check overall object + nparts = len(parts) + assert response['ObjectSize'] == objlen + assert response['Checksum']['ChecksumSHA256'] == upload_checksum + check_parts_count(response['ObjectParts'], nparts) + + # check the parts + partno = 1 + for obj_part in response['ObjectParts']['Parts']: + assert obj_part['PartNumber'] == partno + if partno < len(parts): + assert obj_part['Size'] == 5 * 1024 * 1024 + else: + assert obj_part['Size'] == objlen - ((nparts-1) * (5 * 1024 * 1024)) + assert obj_part['ChecksumSHA256'] == checksums[partno - 1] + partno += 1 + +@pytest.mark.fails_on_dbstore +def test_get_multipart_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + + key = "multipart" + part_size = 5*1024*1024 + objlen = 30*1024*1024 + + (upload_id, data, parts) = _multipart_upload(bucket_name, key, objlen, part_size) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, + UploadId=upload_id, + MultipartUpload={'Parts': parts}) + etag = response['ETag'].strip('"') + assert len(etag) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, \ + ObjectAttributes=request_attributes) + + # check overall object + nparts = len(parts) + assert response['ObjectSize'] == objlen + check_parts_count(response['ObjectParts'], len(parts)) + assert response['ObjectParts']['IsTruncated'] == False + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + + # check the parts + partno = 1 + for obj_part in response['ObjectParts']['Parts']: + assert obj_part['PartNumber'] == partno + assert obj_part['Size'] == part_size + assert 'ChecksumSHA256' not in obj_part + partno += 1 + +@pytest.mark.fails_on_dbstore +def test_get_paginated_multipart_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + + key = "multipart" + part_size = 5*1024*1024 + objlen = 30*1024*1024 + + (upload_id, data, parts) = _multipart_upload(bucket_name, key, objlen, part_size) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, + UploadId=upload_id, + MultipartUpload={'Parts': parts}) + etag = response['ETag'].strip('"') + assert len(etag) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes, + MaxParts=1, PartNumberMarker=3) + + # check overall object + assert response['ObjectSize'] == objlen + check_parts_count(response['ObjectParts'], len(parts)) + assert response['ObjectParts']['MaxParts'] == 1 + assert response['ObjectParts']['PartNumberMarker'] == 3 + assert response['ObjectParts']['IsTruncated'] == True + assert response['ObjectParts']['NextPartNumberMarker'] == 4 + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + + # check the part + assert len(response['ObjectParts']['Parts']) == 1 + obj_part = response['ObjectParts']['Parts'][0] + assert obj_part['PartNumber'] == 4 + assert obj_part['Size'] == part_size + assert 'ChecksumSHA256' not in obj_part + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes, + MaxParts=10, PartNumberMarker=4) + + # check overall object + assert response['ObjectSize'] == objlen + check_parts_count(response['ObjectParts'], len(parts)) + assert response['ObjectParts']['MaxParts'] == 10 + assert response['ObjectParts']['IsTruncated'] == False + assert response['ObjectParts']['PartNumberMarker'] == 4 + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + + # check the parts + assert len(response['ObjectParts']['Parts']) == 2 + partno = 5 + for obj_part in response['ObjectParts']['Parts']: + assert obj_part['PartNumber'] == partno + assert obj_part['Size'] == part_size + assert 'ChecksumSHA256' not in obj_part + partno += 1 + +@pytest.mark.fails_on_dbstore +def test_get_single_multipart_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + + key = "multipart" + part_size = 5*1024*1024 + part_sizes = [part_size] # just one part + part_count = len(part_sizes) + total_size = sum(part_sizes) + + (upload_id, data, parts) = _multipart_upload(bucket_name, key, total_size, part_size) + response = client.complete_multipart_upload(Bucket=bucket_name, Key=key, + UploadId=upload_id, + MultipartUpload={'Parts': parts}) + etag = response['ETag'].strip('"') + assert len(etag) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes) + + assert response['ObjectSize'] == total_size + check_parts_count(response['ObjectParts'], 1) + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + + assert len(response['ObjectParts']['Parts']) == 1 + obj_part = response['ObjectParts']['Parts'][0] + assert obj_part['PartNumber'] == 1 + assert obj_part['Size'] == part_size + assert 'ChecksumSHA256' not in obj_part + +def test_get_checksum_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + + key = "myobj" + size = 1024 + body = FakeWriteFile(size, 'A') + sha256sum = 'arcu6553sHVAiX4MjW0j7I7vD4w6R+Gz9Ok0Q9lTa+0=' + response = client.put_object(Bucket=bucket_name, Key=key, Body=body, ChecksumAlgorithm='SHA256', ChecksumSHA256=sha256sum) + assert sha256sum == response['ChecksumSHA256'] + etag = response['ETag'].strip('"') + assert len(etag) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes) + + assert response['ObjectSize'] == size + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + assert response['Checksum']['ChecksumSHA256'] == sha256sum + assert 'ObjectParts' not in response + +def test_get_versioned_object_attributes(): + bucket_name = get_new_bucket() + check_configure_versioning_retry(bucket_name, "Enabled", "Enabled") + client = get_client() + key = "obj" + objlen = 3 + + response = client.put_object(Bucket=bucket_name, Key=key, Body='foo') + etag = response['ETag'].strip('"') + assert len(etag) + version = response['VersionId'] + assert len(version) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes) + + assert 'DeleteMarker' not in response + assert response['VersionId'] == version + + assert response['ObjectSize'] == 3 + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + assert 'ObjectParts' not in response + + # write a new current version + client.put_object(Bucket=bucket_name, Key=key, Body='foo') + + # ask for the original version again + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, VersionId=version, + ObjectAttributes=request_attributes) + + assert 'DeleteMarker' not in response + assert response['VersionId'] == version + + assert response['ObjectSize'] == 3 + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + assert 'ObjectParts' not in response + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +def test_get_sse_c_encrypted_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + key = 'obj' + objlen = 1000 + data = 'A'*objlen + sse_args = { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'SSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==' + } + attrs = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + + response = client.put_object(Bucket=bucket_name, Key=key, Body=data, **sse_args) + etag = response['ETag'].strip('"') + assert len(etag) + + # GetObjectAttributes fails without sse-c headers + e = assert_raises(ClientError, client.get_object_attributes, + Bucket=bucket_name, Key=key, ObjectAttributes=attrs) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + + # and succeeds sse-c headers + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=attrs, **sse_args) + + assert 'DeleteMarker' not in response + assert 'VersionId' not in response + + assert response['ObjectSize'] == objlen + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + assert 'ObjectParts' not in response + +@pytest.mark.fails_on_dbstore +def test_get_object_attributes(): + bucket_name = get_new_bucket() + client = get_client() + key = "obj" + objlen = 3 + + response = client.put_object(Bucket=bucket_name, Key=key, Body='foo') + etag = response['ETag'].strip('"') + assert len(etag) + + request_attributes = ['ETag', 'Checksum', 'ObjectParts', 'StorageClass', 'ObjectSize'] + response = client.get_object_attributes(Bucket=bucket_name, Key=key, + ObjectAttributes=request_attributes) + + assert 'DeleteMarker' not in response + assert 'VersionId' not in response + + assert response['ObjectSize'] == 3 + assert response['ETag'] == etag + assert response['StorageClass'] == 'STANDARD' + assert 'ObjectParts' not in response + + +def test_upload_part_copy_percent_encoded_key(): + + s3_client = get_client() + bucket_name = get_new_bucket() + key = "anyfile.txt" + encoded_key = "anyfilename%25.txt" + raw_key = "anyfilename%.txt" + + ## PutObject: the copy source + s3_client.put_object( + Bucket=bucket_name, + Key=encoded_key, + Body=b"foo", + ContentType="text/plain" + ) + + # Upload the target object (initial state) + s3_client.put_object( + Bucket=bucket_name, + Key=key, + Body=b"foo", + ContentType="text/plain" + ) + + # Initiate multipart upload + mp_response = s3_client.create_multipart_upload( + Bucket=bucket_name, + Key=key + ) + upload_id = mp_response["UploadId"] + + # The following operation is expected to fail + with pytest.raises(s3_client.exceptions.ClientError) as exc_info: + s3_client.upload_part_copy( + Bucket=bucket_name, + Key=key, + PartNumber=1, + UploadId=upload_id, + CopySource={'Bucket': bucket_name, 'Key': raw_key} + ) + + # Download the object and verify content + final_obj = s3_client.get_object(Bucket=bucket_name, Key=key) + content = final_obj['Body'].read() + assert content == b"foo" + +def check_delete_marker(client, bucket, key, status): + dm_header_val = 'none' + + try: + res = client.head_object(Bucket=bucket, Key=key) + except ClientError as e: + response_headers = e.response['ResponseMetadata']['HTTPHeaders'] + dm_header_val = response_headers['x-amz-delete-marker'] + + assert dm_header_val == status + +@pytest.mark.delete_marker +@pytest.mark.fails_on_dbstore +def test_delete_marker_nonversioned(): + bucket = get_new_bucket() + client = get_client() + key = "frodo.txt" + body = "body version %s" % (key) + # no versioning case + res = client.put_object(Bucket=bucket, Key=key, Body=body) + res = client.delete_object(Bucket=bucket, Key=key) + check_delete_marker(client, bucket, key, 'false') + +@pytest.mark.delete_marker +@pytest.mark.fails_on_dbstore +def test_delete_marker_versioned(): + bucket = get_new_bucket() + client = get_client() + key = "bilbo.txt" + body = "body version %s" % (key) + # versioning case + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + res = client.put_object(Bucket=bucket, Key=key, Body=body) + res = client.delete_object(Bucket=bucket, Key=key) + check_delete_marker(client, bucket, key, 'true') + +@pytest.mark.delete_marker +@pytest.mark.fails_on_dbstore +def test_delete_marker_suspended(): + bucket = get_new_bucket() + client = get_client() + key = "ringo.txt" + body = "body version %s" % (key) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + res = client.put_object(Bucket=bucket, Key=key, Body=body) + check_configure_versioning_retry(bucket, "Suspended", "Suspended") + res = client.delete_object(Bucket=bucket, Key=key) + check_delete_marker(client, bucket, key, 'true') + +@pytest.mark.delete_marker +@pytest.mark.lifecycle +@pytest.mark.lifecycle_expiration +@pytest.mark.fails_on_dbstore +def test_delete_marker_expiration(): + bucket = get_new_bucket() + client = get_client() + key = "nugent.ted" + body = "body version %s" % (key) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + res = client.put_object(Bucket=bucket, Key=key, Body=body) + res = client.delete_object(Bucket=bucket, Key=key) + # object should be a delete marker + check_delete_marker(client, bucket, key, 'true') + + # to observe delete marker expiration, we must expire any existing + # noncurrent version, as these inhibit delete marker expiration + lifecycle = { + 'Rules': [{'Expiration': {'ExpiredObjectDeleteMarker': True}, 'ID': 'dm-1-days', 'Prefix': '', 'Status': 'Enabled'}, {'ID': 'noncur-1-days', 'Prefix': '', 'Status': 'Enabled', 'NoncurrentVersionExpiration': {'NoncurrentDays': 1}}] + } + response = client.put_bucket_lifecycle_configuration(Bucket=bucket, LifecycleConfiguration=lifecycle) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + lc_interval = get_lc_debug_interval() + time.sleep(6*lc_interval) + + # delete marker should have expired + check_delete_marker(client, bucket, key, 'false') + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_put_object_if_match(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + etag = client.put_object(Bucket=bucket, Key=key, IfNoneMatch='*')['ETag'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch='*') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch=etag) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + response = client.put_object(Bucket=bucket, Key=key, IfNoneMatch='badetag') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + + client.put_object(Bucket=bucket, Key=key, IfMatch=etag) + + client.delete_object(Bucket=bucket, Key=key) + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='*') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + + response = client.put_object(Bucket=bucket, Key=key, IfNoneMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + response = client.put_object(Bucket=bucket, Key=key, IfMatch='*') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + response = client.put_object(Bucket=bucket, Key=key, IfMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +def prepare_multipart_upload(client, bucket, key, body): + upload_id = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + response = client.upload_part(UploadId=upload_id, Bucket=bucket, Key=key, PartNumber=1, Body=body) + parts = [{'ETag': response['ETag'].strip('"'), 'PartNumber': 1}] + return upload_id, parts + +def successful_conditional_multipart_upload(client, bucket, key, body='abc', IfMatch=None, IfNoneMatch=None): + upload_id, parts = prepare_multipart_upload(client, bucket, key, body) + if IfMatch: + response = client.complete_multipart_upload(Bucket=bucket, Key=key, IfMatch=IfMatch, UploadId=upload_id, MultipartUpload={'Parts': parts}) + elif IfNoneMatch : + response = client.complete_multipart_upload(Bucket=bucket, Key=key, IfNoneMatch=IfNoneMatch, UploadId=upload_id, MultipartUpload={'Parts': parts}) + else : + response = client.complete_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + return response + +def failing_conditional_multipart_upload(expected_failure, client, bucket, key, body='abc', IfMatch=None, IfNoneMatch=None): + upload_id, parts = prepare_multipart_upload(client, bucket, key, body) + if IfMatch: + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket, Key=key, IfMatch=IfMatch, UploadId=upload_id, MultipartUpload={'Parts': parts}) + elif IfNoneMatch: + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket, Key=key, IfNoneMatch=IfNoneMatch, UploadId=upload_id, MultipartUpload={'Parts': parts}) + else : + e = assert_raises(ClientError, client.complete_multipart_upload, Bucket=bucket, Key=key, UploadId=upload_id, MultipartUpload={'Parts': parts}) + + assert expected_failure == _get_status_and_error_code(e.response) + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_multipart_put_object_if_match(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + response = successful_conditional_multipart_upload(client, bucket, key, IfNoneMatch='*') + etag = response['ETag'] + + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfNoneMatch='*') + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfNoneMatch=etag) + + response = successful_conditional_multipart_upload(client, bucket, key, IfNoneMatch='badetag') + etag = response['ETag'] + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + + response = successful_conditional_multipart_upload(client, bucket, key, IfMatch=etag) + etag = response['ETag'] + + client.delete_object(Bucket=bucket, Key=key) + + failing_conditional_multipart_upload((404, 'NoSuchKey'), client, bucket, key, IfMatch='*') + failing_conditional_multipart_upload((404, 'NoSuchKey'), client, bucket, key, IfMatch='badetag') + + response = successful_conditional_multipart_upload(client, bucket, key, IfNoneMatch=etag) + etag = response['ETag'] + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + response = successful_conditional_multipart_upload(client, bucket, key, IfMatch='*') + etag = response['ETag'] + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfMatch='badetag') + response = successful_conditional_multipart_upload(client, bucket, key, IfMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_put_current_object_if_none_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + data1 = 'data1' + data2 = 'data2' + + response = client.put_object(Bucket=bucket, Key=key, Body=data1, IfNoneMatch='*') + etag = response['ETag'] + + response = client.put_object(Bucket=bucket, Key=key, Body=data2) + etag2 = response['ETag'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch='*') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch=etag2) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + # we can't specify a version, so we only check against current object + response = client.put_object(Bucket=bucket, Key=key, IfNoneMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + response = client.put_object(Bucket=bucket, Key=key, IfNoneMatch='badetag') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + + client.delete_object(Bucket=bucket, Key=key) + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_multipart_put_current_object_if_none_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + data1 = 'data1' + data2 = 'data2' + + response = successful_conditional_multipart_upload(client, bucket, key, data1, IfNoneMatch='*') + etag = response['ETag'] + + response = successful_conditional_multipart_upload(client, bucket, key, data2) + etag2 = response['ETag'] + + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfNoneMatch='*') + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfNoneMatch=etag2) + + # we can't specify a version, so we only check against current object + response = successful_conditional_multipart_upload(client, bucket, key, IfNoneMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + response = successful_conditional_multipart_upload(client, bucket, key, IfNoneMatch='badetag') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + + client.delete_object(Bucket=bucket, Key=key) + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_put_current_object_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + data1 = 'data1' + data2 = 'data2' + etag = 'deadbeef' + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='*') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + + response = client.put_object(Bucket=bucket, Key=key, Body=data1, IfNoneMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + etag = response['ETag'] + versionId = response['VersionId'] + response = client.put_object(Bucket=bucket, Key=key, Body=data2, IfMatch='*') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + etag2 = response['ETag'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch=etag) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + response = client.put_object(Bucket=bucket, Key=key, IfMatch=etag2) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_multipart_put_current_object_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + data1 = 'data1' + data2 = 'data2' + etag = 'deadbeef' + + failing_conditional_multipart_upload((404, 'NoSuchKey'), client, bucket, key, IfMatch='*') + failing_conditional_multipart_upload((404, 'NoSuchKey'), client, bucket, key, IfMatch='badetag') + + response = successful_conditional_multipart_upload(client, bucket, key, data1, IfNoneMatch=etag) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + etag = response['ETag'] + versionId = response['VersionId'] + response = successful_conditional_multipart_upload(client, bucket, key, data2, IfMatch='*') + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + etag2 = response['ETag'] + + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfMatch='badetag') + failing_conditional_multipart_upload((412, 'PreconditionFailed'), client, bucket, key, IfMatch=etag) + response = successful_conditional_multipart_upload(client, bucket, key, IfMatch=etag2) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_put_object_current_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + etag = client.put_object(Bucket=bucket, Key=key, IfNoneMatch='*')['ETag'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch='*') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfNoneMatch=etag) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + client.put_object(Bucket=bucket, Key=key, IfMatch=etag) + + response = client.delete_object(Bucket=bucket, Key=key) + assert response['DeleteMarker'] + + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='*') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (404, 'NoSuchKey') == _get_status_and_error_code(e.response) + + client.put_object(Bucket=bucket, Key=key, IfNoneMatch=etag) + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_if_match(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + etag = client.put_object(Bucket=bucket, Key=key)['ETag'] + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + client.delete_object(Bucket=bucket, Key=key, IfMatch=etag) + + # -ENOENT doesn't raise error in delete op + response = client.delete_object(Bucket=bucket, Key=key, IfMatch='*') + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + response = client.delete_object(Bucket=bucket, Key=key, IfMatch='badetag') + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + + # recreate to test IfMatch='*' + client.put_object(Bucket=bucket, Key=key) + client.delete_object(Bucket=bucket, Key=key, IfMatch='*') + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_current_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + # -ENOENT doesn't raise error in delete op + response = client.delete_object(Bucket=bucket, Key=key, IfMatch='*') + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + response = client.delete_object(Bucket=bucket, Key=key, IfMatch='badetag') + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + + response = client.put_object(Bucket=bucket, Key=key) + version = response['VersionId'] + etag = response['ETag'] + + # on current version + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, IfMatch=etag) + assert response['DeleteMarker'] + + # -ENOENT doesn't raise error in delete op + client.delete_object(Bucket=bucket, Key=key, IfMatch='*') + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + # remove delete marker to retest IfMatch='*' + client.delete_object(Bucket=bucket, Key=key, VersionId=version) + client.delete_object(Bucket=bucket, Key=key, IfMatch='*') + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_version_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + response = client.put_object(Bucket=bucket, Key=key) + version = response['VersionId'] + etag = response['ETag'] + + # on specific version + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, VersionId=version, IfMatch='badetag') + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatch=etag) + assert 'DeleteMarker' not in response + + # -ENOENT doesn't raise error in delete op + client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatch='*') + client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatch='badetag') + + # recreate to test IfMatch='*' + response = client.put_object(Bucket=bucket, Key=key) + version = response['VersionId'] + client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatch='*') + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + client.put_object(Bucket=bucket, Key=key) + mtime = client.head_object(Bucket=bucket, Key=key)['LastModified'] + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchLastModifiedTime=badmtime) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + client.delete_object(Bucket=bucket, Key=key, IfMatchLastModifiedTime=mtime) + + response = client.delete_object(Bucket=bucket, Key=key, IfMatchLastModifiedTime=badmtime) + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_current_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + client.put_object(Bucket=bucket, Key=key) + mtime = client.head_object(Bucket=bucket, Key=key)['LastModified'] + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchLastModifiedTime=badmtime) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, IfMatchLastModifiedTime=mtime) + assert response['DeleteMarker'] + + # object is marked as deleted but still exists + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchLastModifiedTime=badmtime) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_version_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + version = client.put_object(Bucket=bucket, Key=key)['VersionId'] + mtime = client.head_object(Bucket=bucket, Key=key, VersionId=version)['LastModified'] + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, VersionId=version, IfMatchLastModifiedTime=badmtime) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatchLastModifiedTime=mtime) + assert 'DeleteMarker' not in response + + response = client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatchLastModifiedTime=badmtime) + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + size = 0 + badsize = 9999 + + client.put_object(Bucket=bucket, Key=key) + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchSize=badsize) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + client.delete_object(Bucket=bucket, Key=key, IfMatchSize=size) + + response = client.delete_object(Bucket=bucket, Key=key, IfMatchSize=badsize) + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_current_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + size = 0 + badsize = 9999 + + client.put_object(Bucket=bucket, Key=key) + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchSize=badsize) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, IfMatchSize=size) + assert 'DeleteMarker' in response + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, IfMatchSize=badsize) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_object_version_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + size = 0 + badsize = 9999 + + version = client.put_object(Bucket=bucket, Key=key)['VersionId'] + + e = assert_raises(ClientError, client.delete_object, Bucket=bucket, Key=key, VersionId=version, IfMatchSize=badsize) + assert (412, 'PreconditionFailed') == _get_status_and_error_code(e.response) + + response = client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatchSize=size) + assert 'DeleteMarker' not in response + + response = client.delete_object(Bucket=bucket, Key=key, VersionId=version, IfMatchSize=badsize) + assert 204 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_if_match(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + etag = client.put_object(Bucket=bucket, Key=key)['ETag'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': 'badetag'}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': etag}]}) + assert key == response['Deleted'][0]['Key'] # success + + # -ENOENT doesn't raise error in delete op + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': 'badetag'}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_current_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + etag = client.put_object(Bucket=bucket, Key=key)['ETag'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': 'badetag'}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': etag}]}) + assert key == response['Deleted'][0]['Key'] # success + assert response['Deleted'][0]['DeleteMarker'] + + # object is marked as deleted but still exists + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'ETag': 'badetag'}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_version_if_match(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + response = client.put_object(Bucket=bucket, Key=key) + etag = response['ETag'] + version = response['VersionId'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'ETag': 'badetag'}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'ETag': etag}]}) + assert key == response['Deleted'][0]['Key'] # success + assert 'DeleteMarker' not in response['Deleted'][0] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'ETag': 'badetag'}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + client.put_object(Bucket=bucket, Key=key) + mtime = client.head_object(Bucket=bucket, Key=key)['LastModified'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': badmtime}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': mtime}]}) + assert key == response['Deleted'][0]['Key'] # success + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': badmtime}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_current_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + client.put_object(Bucket=bucket, Key=key) + mtime = client.head_object(Bucket=bucket, Key=key)['LastModified'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': badmtime}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': mtime}]}) + assert key == response['Deleted'][0]['Key'] # success + assert response['Deleted'][0]['DeleteMarker'] + + # object exists, but marked as deleted + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'LastModifiedTime': badmtime}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_version_if_match_last_modified_time(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + badmtime = datetime.datetime(2015, 1, 1) + + version = client.put_object(Bucket=bucket, Key=key)['VersionId'] + mtime = client.head_object(Bucket=bucket, Key=key, VersionId=version)['LastModified'] + + # create a different version as current + client.put_object(Bucket=bucket, Key=key) + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'LastModifiedTime': badmtime}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'LastModifiedTime': mtime}]}) + assert key == response['Deleted'][0]['Key'] # success + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'LastModifiedTime': badmtime}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + key = 'obj' + + size = 0 + badsize = 9999 + + client.put_object(Bucket=bucket, Key=key) + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': badsize}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': size}]}) + assert key == response['Deleted'][0]['Key'] # success + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': badsize}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_current_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + size = 0 + badsize = 9999 + + client.put_object(Bucket=bucket, Key=key) + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': badsize}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': size}]}) + assert key == response['Deleted'][0]['Key'] # success + assert response['Deleted'][0]['DeleteMarker'] + + # object exists, but marked as deleted + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'Size': badsize}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + +@pytest.mark.fails_on_aws # only supported for directory buckets +@pytest.mark.conditional_write +@pytest.mark.fails_on_dbstore +def test_delete_objects_version_if_match_size(): + client = get_client() + bucket = get_new_bucket(client) + check_configure_versioning_retry(bucket, "Enabled", "Enabled") + key = 'obj' + + size = 0 + badsize = 9999 + + version = client.put_object(Bucket=bucket, Key=key)['VersionId'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'Size': badsize}]}) + assert 'PreconditionFailed' == response['Errors'][0]['Code'] + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'Size': size}]}) + assert key == response['Deleted'][0]['Key'] # success + + response = client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': key, 'VersionId': version, 'Size': badsize}]}) + assert 200 == response['ResponseMetadata']['HTTPStatusCode'] + +def public_bucket_policy(bucket): + return json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "*", + "Resource": [ + f"arn:aws:s3:::{bucket}", + f"arn:aws:s3:::{bucket}/*" + ] + }] + }) + +def get_object_acl_owner(client, bucket, key): + response = client.get_object_acl(Bucket=bucket, Key=key) + return response['Owner']['ID'], response['Owner']['DisplayName'] + +def get_multipart_acl_owner(client, bucket, key, upload_id): + response = client.list_parts(Bucket=bucket, Key=key, UploadId=upload_id) + return response['Owner']['ID'], response['Owner']['DisplayName'] + +def _test_object_ownership_bucket_owner_enforced(client, bucket, bucket_owner): + # put_object() succeeds without ACL + client.put_object(Bucket=bucket, Key='put-object-no-acl') + assert bucket_owner == get_object_acl_owner(client, bucket, 'put-object-no-acl') + # put_object() succeeds with ACL=bucket-owner-full-control + client.put_object(Bucket=bucket, Key='put-object-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner == get_object_acl_owner(client, bucket, 'put-object-bucket-owner-full-control') + # put_object() fails with other ACL + e = assert_raises(ClientError, client.put_object, Bucket=bucket, Key='put-object-private', ACL='private') + assert (400, 'AccessControlListNotSupported') == _get_status_and_error_code(e.response) + + # create_multipart_upload() succeeds without ACL + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-no-acl') + assert bucket_owner == get_multipart_acl_owner(client, bucket, 'create-multipart-upload-no-acl', response['UploadId']) + # create_multipart_upload() succeeds with ACL=bucket-owner-full-control + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner == get_multipart_acl_owner(client, bucket, 'create-multipart-upload-bucket-owner-full-control', response['UploadId']) + # create_multipart_upload() fails with other ACL + e = assert_raises(ClientError, client.create_multipart_upload, Bucket=bucket, Key='create-multipart-upload-private', ACL='private') + assert (400, 'AccessControlListNotSupported') == _get_status_and_error_code(e.response) + + # copy_object() succeeds without ACL + client.copy_object(Bucket=bucket, Key='copy-object-no-acl', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}) + assert bucket_owner == get_object_acl_owner(client, bucket, 'copy-object-no-acl') + # copy_object() succeeds with ACL=bucket-owner-full-control + client.copy_object(Bucket=bucket, Key='copy-object-bucket-owner-full-control', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='bucket-owner-full-control') + assert bucket_owner == get_object_acl_owner(client, bucket, 'copy-object-bucket-owner-full-control') + # copy_object() fails with other ACL + e = assert_raises(ClientError, client.copy_object, Bucket=bucket, Key='copy-object-private', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='private') + assert (400, 'AccessControlListNotSupported') == _get_status_and_error_code(e.response) + + # put_bucket_acl() fails + e = assert_raises(ClientError, client.put_bucket_acl, Bucket=bucket, ACL='private') + assert (400, 'AccessControlListNotSupported') == _get_status_and_error_code(e.response) + # put_object_acl() fails + e = assert_raises(ClientError, client.put_object_acl, Bucket=bucket, Key='put-object-no-acl', ACL='private') + assert (400, 'AccessControlListNotSupported') == _get_status_and_error_code(e.response) + +def _test_object_ownership_bucket_owner_preferred(client, bucket, bucket_owner): + # put_object() without ACL owned by client + client.put_object(Bucket=bucket, Key='put-object-no-acl') + assert bucket_owner != get_object_acl_owner(client, bucket, 'put-object-no-acl') + # put_object() with ACL=bucket-owner-full-control owned by bucket owner + client.put_object(Bucket=bucket, Key='put-object-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner == get_object_acl_owner(client, bucket, 'put-object-bucket-owner-full-control') + # put_object() with other ACL owned by client + client.put_object(Bucket=bucket, Key='put-object-private', ACL='private') + assert bucket_owner != get_object_acl_owner(client, bucket, 'put-object-private') + + # create_multipart_upload() without ACL owned by client + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-no-acl') + assert bucket_owner != get_multipart_acl_owner(client, bucket, 'create-multipart-upload-no-acl', response['UploadId']) + # create_multipart_upload() with ACL=bucket-owner-full-control owned by bucket owner + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner == get_multipart_acl_owner(client, bucket, 'create-multipart-upload-bucket-owner-full-control', response['UploadId']) + # create_multipart_upload() with other ACL owned by client + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-private', ACL='private') + assert bucket_owner != get_multipart_acl_owner(client, bucket, 'create-multipart-upload-private', response['UploadId']) + + # copy_object() without ACL owned by client + client.copy_object(Bucket=bucket, Key='copy-object-no-acl', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}) + assert bucket_owner != get_object_acl_owner(client, bucket, 'copy-object-no-acl') + # copy_object() with ACL=bucket-owner-full-control owned by bucket owner + client.copy_object(Bucket=bucket, Key='copy-object-bucket-owner-full-control', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='bucket-owner-full-control') + assert bucket_owner == get_object_acl_owner(client, bucket, 'copy-object-bucket-owner-full-control') + # copy_object() with other ACL owned by client + client.copy_object(Bucket=bucket, Key='copy-object-private', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='private') + assert bucket_owner != get_object_acl_owner(client, bucket, 'copy-object-private') + + # put_bucket_acl() and put_object_acl() succeed + client.put_bucket_acl(Bucket=bucket, ACL='private') + client.put_object_acl(Bucket=bucket, Key='put-object-no-acl', ACL='private') + +def _test_object_ownership_object_writer(client, bucket, bucket_owner): + # put_object() without ACL owned by client + client.put_object(Bucket=bucket, Key='put-object-no-acl') + assert bucket_owner != get_object_acl_owner(client, bucket, 'put-object-no-acl') + # put_object() with ACL=bucket-owner-full-control owned by client + client.put_object(Bucket=bucket, Key='put-object-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner != get_object_acl_owner(client, bucket, 'put-object-bucket-owner-full-control') + # put_object() with other ACL owned by client + client.put_object(Bucket=bucket, Key='put-object-private', ACL='private') + assert bucket_owner != get_object_acl_owner(client, bucket, 'put-object-private') + + # create_multipart_upload() without ACL owned by client + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-no-acl') + assert bucket_owner != get_multipart_acl_owner(client, bucket, 'create-multipart-upload-no-acl', response['UploadId']) + # create_multipart_upload() with ACL=bucket-owner-full-control owned by client + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-bucket-owner-full-control', ACL='bucket-owner-full-control') + assert bucket_owner != get_multipart_acl_owner(client, bucket, 'create-multipart-upload-bucket-owner-full-control', response['UploadId']) + # create_multipart_upload() with other ACL owned by client + response = client.create_multipart_upload(Bucket=bucket, Key='create-multipart-upload-private', ACL='private') + assert bucket_owner != get_multipart_acl_owner(client, bucket, 'create-multipart-upload-private', response['UploadId']) + + # copy_object() without ACL owned by client + client.copy_object(Bucket=bucket, Key='copy-object-no-acl', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}) + assert bucket_owner != get_object_acl_owner(client, bucket, 'copy-object-no-acl') + # copy_object() with ACL=bucket-owner-full-control owned by client + client.copy_object(Bucket=bucket, Key='copy-object-bucket-owner-full-control', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='bucket-owner-full-control') + assert bucket_owner != get_object_acl_owner(client, bucket, 'copy-object-bucket-owner-full-control') + # copy_object() with other ACL owned by client + client.copy_object(Bucket=bucket, Key='copy-object-private', CopySource={'Bucket': bucket, 'Key': 'put-object-no-acl'}, ACL='private') + assert bucket_owner != get_object_acl_owner(client, bucket, 'copy-object-private') + + # put_bucket_acl() and put_object_acl() succeed + client.put_bucket_acl(Bucket=bucket, ACL='private') + client.put_object_acl(Bucket=bucket, Key='put-object-no-acl', ACL='private') + +def get_bucket_ownership(client, bucket): + response = client.get_bucket_ownership_controls(Bucket=bucket) + assert 1 == len(response['OwnershipControls']['Rules']) + return response['OwnershipControls']['Rules'][0]['ObjectOwnership'] + +@pytest.mark.object_ownership +@pytest.mark.fails_on_aws # aws defaults to BucketOwnerEnforced +def test_create_bucket_no_ownership_controls(): + client = get_client() + bucket = get_new_bucket() + e = assert_raises(ClientError, client.get_bucket_ownership_controls, Bucket=bucket) + assert (404, 'OwnershipControlsNotFoundError') == _get_status_and_error_code(e.response) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_create_bucket_bucket_owner_enforced(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket_name() + client.create_bucket(Bucket=bucket, ObjectOwnership='BucketOwnerEnforced') + assert 'BucketOwnerEnforced' == get_bucket_ownership(client, bucket) + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_bucket_owner_enforced(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_create_bucket_bucket_owner_preferred(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket_name() + client.create_bucket(Bucket=bucket, ObjectOwnership='BucketOwnerPreferred') + assert 'BucketOwnerPreferred' == get_bucket_ownership(client, bucket) + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_bucket_owner_preferred(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_create_bucket_object_writer(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket_name() + client.create_bucket(Bucket=bucket, ObjectOwnership='ObjectWriter') + assert 'ObjectWriter' == get_bucket_ownership(client, bucket) + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_object_writer(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_put_bucket_ownership_bucket_owner_enforced(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket_name() + ownership = {'Rules': [{'ObjectOwnership': 'BucketOwnerEnforced'}]} + + # expect PutBucketOwnershipControls to fail with public-read ACL + client.create_bucket(Bucket=bucket, ACL='public-read') + e = assert_raises(ClientError, client.put_bucket_ownership_controls, + Bucket=bucket, OwnershipControls=ownership) + status, error_code = _get_status_and_error_code(e.response) + assert status == 400 + assert error_code == 'InvalidBucketAclWithObjectOwnership' + + # expect success with default private ACL + client.put_bucket_acl(Bucket=bucket, ACL='private') + client.put_bucket_ownership_controls(Bucket=bucket, OwnershipControls=ownership) + assert 'BucketOwnerEnforced' == get_bucket_ownership(client, bucket) + + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_bucket_owner_enforced(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_put_bucket_ownership_bucket_owner_preferred(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket(client) + ownership = {'Rules': [{'ObjectOwnership': 'BucketOwnerPreferred'}]} + client.put_bucket_ownership_controls(Bucket=bucket, OwnershipControls=ownership) + assert 'BucketOwnerPreferred' == get_bucket_ownership(client, bucket) + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_bucket_owner_preferred(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +@pytest.mark.fails_on_dbstore +def test_put_bucket_ownership_object_writer(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket(client) + ownership = {'Rules': [{'ObjectOwnership': 'ObjectWriter'}]} + client.put_bucket_ownership_controls(Bucket=bucket, OwnershipControls=ownership) + assert 'ObjectWriter' == get_bucket_ownership(client, bucket) + # add public bucket policy and test with 'alt' user + client.put_bucket_policy(Bucket=bucket, Policy=public_bucket_policy(bucket)) + _test_object_ownership_object_writer(get_alt_client(), bucket, bucket_owner) + +@pytest.mark.object_ownership +def test_bucket_create_delete_bucket_ownership(): + client = get_client() + bucket_owner = (get_main_user_id(), get_main_display_name()) + bucket = get_new_bucket(client) + ownership = {'Rules': [{'ObjectOwnership': 'BucketOwnerEnforced'}]} + client.put_bucket_ownership_controls(Bucket=bucket, OwnershipControls=ownership) + assert 'BucketOwnerEnforced' == get_bucket_ownership(client, bucket) + + client.delete_bucket_ownership_controls(Bucket=bucket) + + e = assert_raises(ClientError, client.get_bucket_ownership_controls, Bucket=bucket) + assert (404, 'OwnershipControlsNotFoundError') == _get_status_and_error_code(e.response) + + client.delete_bucket_ownership_controls(Bucket=bucket) + +def test_head_object_404_with_policy_prefix(): + client = get_client() + bucket = get_new_bucket(client) + + policy = json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "s3:ListBucket", + "Resource": f"arn:aws:s3:::{bucket}", + "Condition": { + "StringLike": { + "s3:prefix": "public/*" + } + } + }] + }) + client.put_bucket_policy(Bucket=bucket, Policy=policy) + + alt_client = get_alt_client() + # expect 404 NoSuchKey for names that match the s3:prefix + e = assert_raises(ClientError, alt_client.head_object, Bucket=bucket, Key='public/object') + assert 404 == _get_status(e.response) + # expect 403 Forbidden for names that don't match + e = assert_raises(ClientError, alt_client.head_object, Bucket=bucket, Key='private/object') + assert 403 == _get_status(e.response) + +######################### +# COPY ENCRYPTION TESTS # +######################### +_copy_enc_source_modes = { + 'unencrypted': { + 'marks': [pytest.mark.fails_on_aws], + }, + 'sse-s3': { + 'args': {'ServerSideEncryption': 'AES256'}, + 'assert': lambda r: r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256', + 'marks': [pytest.mark.sse_s3], + }, + 'sse-c': { + 'args': { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'SSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==', + }, + 'get_args': { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'SSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==', + }, + 'source_copy_args': { + 'CopySourceSSECustomerAlgorithm': 'AES256', + 'CopySourceSSECustomerKey': 'pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=', + 'CopySourceSSECustomerKeyMD5': 'DWygnHRtgiJ77HCm+1rvHw==', + }, + }, + 'sse-kms': { + 'args': { + 'ServerSideEncryption': 'aws:kms', + 'SSEKMSKeyId': lambda: get_main_kms_keyid() + }, + } +} +_copy_enc_dest_modes = { + 'unencrypted': { + 'marks': [pytest.mark.fails_on_aws], + }, + 'sse-s3': { + 'args': {'ServerSideEncryption': 'AES256'}, + 'assert': lambda r: r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'AES256', + 'marks': [pytest.mark.sse_s3], + }, + 'sse-c': { + 'args': { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': '6b+WOZ1T3cqZMxgThRcXAQBrS5mXKdDUphvpxptl9/4=', + 'SSECustomerKeyMD5': 'arxBvwY2V4SiOne6yppVPQ==' + }, + 'get_args': { + 'SSECustomerAlgorithm': 'AES256', + 'SSECustomerKey': '6b+WOZ1T3cqZMxgThRcXAQBrS5mXKdDUphvpxptl9/4=', + 'SSECustomerKeyMD5': 'arxBvwY2V4SiOne6yppVPQ==' + }, + 'assert': lambda r: ( + r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-customer-algorithm'] == 'AES256' and + r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-customer-key-md5'] == 'arxBvwY2V4SiOne6yppVPQ==' + ) + }, + 'sse-kms': { + 'args': { + 'ServerSideEncryption': 'aws:kms', + 'SSEKMSKeyId': lambda: get_secondary_kms_keyid() + }, + 'assert': lambda r: ( + r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption'] == 'aws:kms' and + r['ResponseMetadata']['HTTPHeaders']['x-amz-server-side-encryption-aws-kms-key-id'] == get_secondary_kms_keyid() + ) + } +} + +def _test_copy_enc(file_size, source_mode_key, dest_mode_key, source_sc=None, dest_sc=None): + source_args = _copy_enc_source_modes[source_mode_key] + dest_args = _copy_enc_dest_modes[dest_mode_key] + + bucket_name = get_new_bucket() + client = get_client() + + # upload original file with source encryption + data = 'A'*file_size + args = {key: value() if callable(value) else value for key, value in source_args.get('args', {}).items()} + if source_sc: + args['StorageClass'] = source_sc + response = client.put_object(Bucket=bucket_name, Key='testobj', Body=data, **args) + assert source_args.get('assert', lambda r: True)(response) + + # copy the object to a new key, with destination encryption + dest_bucket_name = get_new_bucket() + copy_args = {key: value() if callable(value) else value for key, value in dest_args.get('args', {}).items()} + copy_args.update(source_args.get('source_copy_args', {})) + if dest_sc: + copy_args['StorageClass'] = dest_sc + response = client.copy_object(Bucket=dest_bucket_name, Key='testobj2', CopySource={'Bucket': bucket_name, 'Key': 'testobj'}, **copy_args) + assert dest_args.get('assert', lambda r: True)(response) + + # verify the copy is encrypted + get_args = dest_args.get('get_args', {}) + response = client.get_object(Bucket=dest_bucket_name, Key='testobj2', **get_args) + assert dest_args.get('assert', lambda r: True)(response) + body = _get_body(response) + assert body == data + +def _test_copy_part_enc(file_size, source_mode_key, dest_mode_key, source_sc=None, dest_sc=None): + source_args = _copy_enc_source_modes[source_mode_key] + dest_args = _copy_enc_dest_modes[dest_mode_key] + + bucket_name = get_new_bucket() + client = get_client() + + # upload original file with source encryption + data = 'A'*file_size + args = {key: value() if callable(value) else value for key, value in source_args.get('args', {}).items()} + if source_sc: + args['StorageClass'] = source_sc + response = client.put_object(Bucket=bucket_name, Key='testobj', Body=data, **args) + assert source_args.get('assert', lambda r: True)(response) + + # create a multipart upload with source encryption + dest_bucket_name = get_new_bucket() + upload_args = {key: value() if callable(value) else value for key, value in dest_args.get('args', {}).items()} + if dest_sc: + upload_args['StorageClass'] = dest_sc + response = client.create_multipart_upload(Bucket=dest_bucket_name, Key='testobj2', **upload_args) + assert dest_args.get('assert', lambda r: True)(response) + upload_id = response['UploadId'] + assert len(upload_id) + + parts = [] + + # copy the object as the part + copy_args = {key: value() if callable(value) else value for key, value in source_args.get('source_copy_args', {}).items()} + + # verify sse-c headers + if dest_mode_key == 'sse-c': + # make sure api is verifying the SSE-C headers + e = assert_raises(ClientError, client.upload_part_copy, + Bucket=dest_bucket_name, Key='testobj2', + PartNumber=1, UploadId=upload_id, + CopySource={'Bucket': bucket_name, 'Key': 'testobj'}, + **copy_args) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + # and use the source key to copy the part + source_sse_c_args = _copy_enc_source_modes['sse-c']['source_copy_args'] + wrong_copy_args = copy_args.copy() + wrong_copy_args.update(source_sse_c_args) + wrong_copy_args.pop('StorageClass', None) # StorageClass is not allowed in copy part + e = assert_raises(ClientError, client.upload_part_copy, + Bucket=dest_bucket_name, Key='testobj2', + PartNumber=1, UploadId=upload_id, + CopySource={'Bucket': bucket_name, 'Key': 'testobj'}, + **wrong_copy_args) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + if dest_mode_key == 'sse-c': + copy_args.update(upload_args) + if dest_sc: + copy_args.pop('StorageClass', None) # StorageClass is not allowed in copy part + response = client.upload_part_copy( + Bucket=dest_bucket_name, + Key='testobj2', + PartNumber=1, + UploadId=upload_id, + CopySource={'Bucket': bucket_name, 'Key': 'testobj'}, + **copy_args + ) + assert dest_args.get('assert', lambda r: True)(response) + parts.append({ + 'ETag': response['CopyPartResult']['ETag'], + 'PartNumber': 1 + }) + + # add another temporary part to the upload + complete_args = {} + + # verify sse-c headers + if dest_mode_key == 'sse-c': + # make sure api is verifying the SSE-C headers + e = assert_raises(ClientError, client.upload_part, + Bucket=dest_bucket_name, Key='testobj2', + PartNumber=2, UploadId=upload_id, + Body='B'*file_size, + **complete_args) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + # and use the source key to upload the part + source_sse_c_args = _copy_enc_source_modes['sse-c']['args'] + wrong_upload_args = complete_args.copy() + wrong_upload_args.update(source_sse_c_args) + wrong_upload_args.pop('StorageClass', None) # StorageClass is not allowed in upload part + e = assert_raises(ClientError, client.upload_part, + Bucket=dest_bucket_name, Key='testobj2', + PartNumber=2, UploadId=upload_id, + Body='B'*file_size, + **wrong_upload_args) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + if dest_mode_key == 'sse-c': + complete_args.update(upload_args) + if dest_sc: + complete_args.pop('StorageClass', None) # StorageClass is not allowed in complete multipart upload + temp_part = client.upload_part( + Bucket=dest_bucket_name, + Key='testobj2', + PartNumber=2, + UploadId=upload_id, + Body='B'*file_size, + **complete_args + ) + assert dest_args.get('assert', lambda r: True)(temp_part) + parts.append({ + 'ETag': temp_part['ETag'], + 'PartNumber': 2 + }) + + if dest_mode_key == 'sse-c': + # make sure api is verifying the SSE-C headers + e = assert_raises(ClientError, client.complete_multipart_upload, + Bucket=dest_bucket_name, Key='testobj2', + UploadId=upload_id, MultipartUpload={'Parts': parts}) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + # and the key would be the same as the one used in upload part + # use the source key to complete the upload + # this is not allowed, so we expect an error + source_sse_c_args = _copy_enc_source_modes['sse-c']['args'] + e = assert_raises(ClientError, client.complete_multipart_upload, + Bucket=dest_bucket_name, Key='testobj2', + UploadId=upload_id, MultipartUpload={'Parts': parts}, + **source_sse_c_args) + status, _ = _get_status_and_error_code(e.response) + assert status == 400 + + # complete the multipart upload + response = client.complete_multipart_upload( + Bucket=dest_bucket_name, + Key='testobj2', + UploadId=upload_id, + MultipartUpload={'Parts': parts}, + **complete_args + ) + assert dest_args.get('assert', lambda r: True)(response) + + # verify the copy is encrypted + get_args = dest_args.get('get_args', {}) + response = client.get_object(Bucket=dest_bucket_name, Key='testobj2', **get_args) + assert dest_args.get('assert', lambda r: True)(response) + body = _get_body(response) + assert body == (data + 'B'*file_size) + +def generate_copy_part_enc_params(): + configure() + sc = configured_storage_classes() + + obj_sizes = [8*1024*1024] # min multipart is 5MB + params = [] + for source_key in _copy_enc_source_modes.keys(): + for dest_key in _copy_enc_dest_modes.keys(): + source_marks = _copy_enc_source_modes[source_key].get('marks', []) + dest_marks = _copy_enc_dest_modes[dest_key].get('marks', []) + for source_sc in sc: + for dest_sc in sc: + additional_marks = [] + if source_sc != 'STANDARD' or dest_sc != 'STANDARD': + # storage classes are not supported on AWS + additional_marks.append(pytest.mark.fails_on_aws) + for obj_size in obj_sizes: + param = pytest.param( + source_key, + dest_key, + source_sc, + dest_sc, + obj_size, + marks=[*source_marks, *dest_marks, *additional_marks] + ) + params.append(param) + return params + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +@pytest.mark.parametrize( + "source_mode_key, dest_mode_key, source_storage_class, dest_storage_class, obj_size", + generate_copy_part_enc_params() +) +def test_copy_part_enc(source_mode_key, dest_mode_key, source_storage_class, dest_storage_class, obj_size): + print( + f"Testing copy part from {source_mode_key} to {dest_mode_key} with storage class " + f"{source_storage_class} -> {dest_storage_class} and object size {obj_size}" + ) + _test_copy_part_enc(obj_size, source_mode_key, dest_mode_key, source_storage_class, dest_storage_class) + +def generate_copy_enc_params(): + configure() + sc = configured_storage_classes() + + obj_sizes = [1, 1024, 1024*1024, 8*1024*1024] + + params = [] + for source_key in _copy_enc_source_modes.keys(): + for dest_key in _copy_enc_dest_modes.keys(): + source_marks = _copy_enc_source_modes[source_key].get('marks', []) + dest_marks = _copy_enc_dest_modes[dest_key].get('marks', []) + + for source_sc in sc: + for dest_sc in sc: + additional_marks = [] + if source_sc != 'STANDARD' or dest_sc != 'STANDARD': + additional_marks.extend([ + pytest.mark.fails_on_aws, # storage classes are not supported on AWS + pytest.mark.storage_class, + ]) + for obj_size in obj_sizes: + param = pytest.param( + source_key, + dest_key, + source_sc, + dest_sc, + obj_size, + marks=[*source_marks, *dest_marks, *additional_marks] + ) + params.append(param) + return params + +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +@pytest.mark.parametrize( + "source_mode_key, dest_mode_key, source_storage_class, dest_storage_class, obj_size", + generate_copy_enc_params() +) +def test_copy_enc(source_mode_key, dest_mode_key, source_storage_class, dest_storage_class, obj_size): + print( + f"Testing copy from {source_mode_key} to {dest_mode_key} with storage class " + f"{source_storage_class} -> {dest_storage_class} and object size {obj_size}" + ) + _test_copy_enc(obj_size, source_mode_key, dest_mode_key, source_storage_class, dest_storage_class) + +def generate_lifecycle_transition_params(): + configure() + sc = configured_storage_classes() + if len(sc) < 2: + return [] + + params = [] + for source_key in _copy_enc_source_modes.keys(): + source_marks = _copy_enc_source_modes[source_key].get('marks', []) + + for source_sc in sc: + for dest_sc in sc: + if source_sc == dest_sc: + continue + + params.append(pytest.param( + source_key, + source_sc, + dest_sc, + marks=source_marks + )) + + return params + +def _test_lifecycle_transition(source_mode_key, source_sc=None, dest_sc=None): + source_args = _copy_enc_source_modes[source_mode_key] + args = {key: value() if callable(value) else value for key, value in source_args.get('args', {}).items()} + if source_sc: + args['StorageClass'] = source_sc + + bucket_name = _create_objects(keys=['expire1/foo', 'expire1/bar'], put_object_args=args) + client = get_client() + rules=[{'ID': 'rule1', 'Prefix': '', 'Transitions': [{'Days': 1, 'StorageClass': dest_sc}], 'Status': 'Enabled'}] + lifecycle = {'Rules': rules} + client.put_bucket_lifecycle_configuration(Bucket=bucket_name, LifecycleConfiguration=lifecycle) + + # Get list of all keys + response = client.list_objects(Bucket=bucket_name) + init_keys = _get_keys(response) + assert len(init_keys) == 2 + + lc_interval = get_lc_debug_interval() + + # Wait for expiration + time.sleep(4*lc_interval) + expire1_keys = list_bucket_storage_class(client, bucket_name) + assert len(expire1_keys[source_sc]) == 0 + assert len(expire1_keys[dest_sc]) == 2 + + # retrieve the objects + get_args = source_args.get('get_args', {}) + for key in init_keys: + response = client.get_object(Bucket=bucket_name, Key=key, **get_args) + body = _get_body(response) + assert body == key + +@pytest.mark.lifecycle +@pytest.mark.lifecycle_transition +@pytest.mark.fails_on_aws +@pytest.mark.encryption +@pytest.mark.fails_on_dbstore +@pytest.mark.parametrize( + "source_mode_key, source_storage_class, dest_storage_class", + generate_lifecycle_transition_params() +) +def test_lifecycle_transition_encrypted(source_mode_key, source_storage_class, dest_storage_class): + if len(configured_storage_classes()) < 2: + pytest.skip('need at least two storage classes to test lifecycle transition') + + print( + f"Testing lifecycle transition of {source_mode_key} with storage class {source_storage_class} -> {dest_storage_class}" + ) + _test_lifecycle_transition(source_mode_key, source_storage_class, dest_storage_class) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_s3control.py b/src/test/rgw/s3-tests/s3tests/functional/test_s3control.py new file mode 100644 index 00000000000..afc3ebce2c8 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_s3control.py @@ -0,0 +1,90 @@ +import boto3 +from botocore.exceptions import ClientError +import json +import pytest + +from . import ( + configfile, + setup_teardown, + get_iam_root_client, + get_iam_root_account_id, + get_new_bucket_name, + ) +from .utils import ( + assert_raises, + _get_status_and_error_code, + ) + +@pytest.mark.s3control +def test_account_public_access_block(): + s3control = get_iam_root_client(service_name='s3control', region_name='us-east-1') + account_id = get_iam_root_account_id() + + # delete default configuration if it exists + response = s3control.delete_public_access_block(AccountId=account_id) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + # re-delete should still return 204 + response = s3control.delete_public_access_block(AccountId=account_id) + assert response['ResponseMetadata']['HTTPStatusCode'] == 204 + + # get returns 404 + e = assert_raises(ClientError, s3control.get_public_access_block, AccountId=account_id) + assert (404, 'NoSuchPublicAccessBlockConfiguration') == _get_status_and_error_code(e.response) + + s3control.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={ + 'BlockPublicAcls': True, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': False, + 'RestrictPublicBuckets': False + }) + try: + response = s3control.get_public_access_block(AccountId=account_id) + assert response['PublicAccessBlockConfiguration']['BlockPublicAcls'] + assert not response['PublicAccessBlockConfiguration']['IgnorePublicAcls'] + assert not response['PublicAccessBlockConfiguration']['BlockPublicPolicy'] + assert not response['PublicAccessBlockConfiguration']['RestrictPublicBuckets'] + + s3 = get_iam_root_client(service_name='s3') + bucket = get_new_bucket_name() + + # reject CreateBucket with public acls + e = assert_raises(ClientError, s3.create_bucket, Bucket=bucket, ACL='public-read') + assert (403, 'AccessDenied') == _get_status_and_error_code(e.response) + + s3.create_bucket(Bucket=bucket) + try: + # reject PutBucketAcl with public acls + e = assert_raises(ClientError, s3.put_bucket_acl, Bucket=bucket, ACL='public-read') + assert (403, 'AccessDenied') == _get_status_and_error_code(e.response) + + # test interaction with bucket-level configuration + s3.put_public_access_block( + Bucket=bucket, + PublicAccessBlockConfiguration={ + 'BlockPublicAcls': False, + 'IgnorePublicAcls': False, + 'BlockPublicPolicy': True, + 'RestrictPublicBuckets': False + }) + public_policy = json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "*", + "Resource": [ + f"arn:aws:s3:::{bucket}", + f"arn:aws:s3:::{bucket}/*" + ] + }] + }) + # reject PutBucketPolicy with public policy based on bucket config + e = assert_raises(ClientError, s3.put_bucket_policy, + Bucket=bucket, Policy=public_policy) + assert (403, 'AccessDenied') == _get_status_and_error_code(e.response) + finally: + s3.delete_bucket(Bucket=bucket) + finally: + s3control.delete_public_access_block(AccountId=account_id) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_s3select.py b/src/test/rgw/s3-tests/s3tests/functional/test_s3select.py new file mode 100644 index 00000000000..1c0587adf89 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_s3select.py @@ -0,0 +1,1685 @@ +import pytest +import random +import string +import re +import json +from botocore.exceptions import ClientError +from botocore.exceptions import EventStreamError + +import uuid +import warnings +import traceback + +from . import ( + configfile, + setup_teardown, + get_client, + get_new_bucket_name + ) + +import logging +logging.basicConfig(level=logging.INFO) + +import collections +collections.Callable = collections.abc.Callable + +region_name = '' + +# recurssion function for generating arithmetical expression +def random_expr(depth): + # depth is the complexity of expression + if depth==1 : + return str(int(random.random() * 100) + 1)+".0" + return '(' + random_expr(depth-1) + random.choice(['+','-','*','/']) + random_expr(depth-1) + ')' + + +def generate_s3select_where_clause(bucket_name,obj_name): + + a=random_expr(4) + b=random_expr(4) + s=random.choice([ '<','>','=','<=','>=','!=' ]) + + try: + eval( a ) + eval( b ) + except ZeroDivisionError: + return + + # generate s3select statement using generated randome expression + # upon count(0)>0 it means true for the where clause expression + # the python-engine {eval( conditional expression )} should return same boolean result. + s3select_stmt = "select count(0) from s3object where " + a + s + b + ";" + + res = remove_xml_tags_from_result( run_s3select(bucket_name,obj_name,s3select_stmt) ).replace(",","") + + if s == '=': + s = '==' + + s3select_assert_result(int(res)>0 , eval( a + s + b )) + +def generate_s3select_expression_projection(bucket_name,obj_name): + + # generate s3select statement using generated randome expression + # statement return an arithmetical result for the generated expression. + # the same expression is evaluated by python-engine, result should be close enough(Epsilon) + + e = random_expr( 4 ) + + try: + eval( e ) + except ZeroDivisionError: + return + + if eval( e ) == 0: + return + + res = remove_xml_tags_from_result( run_s3select(bucket_name,obj_name,"select " + e + " from s3object;",) ).replace(",","") + + # accuracy level + epsilon = float(0.00001) + + # both results should be close (epsilon) + assert( abs(float(res.split("\n")[0]) - eval(e)) < epsilon ) + +@pytest.mark.s3select +def get_random_string(): + + return uuid.uuid4().hex[:6].upper() + +@pytest.mark.s3select +def test_generate_where_clause(): + + # create small csv file for testing the random expressions + single_line_csv = create_random_csv_object(1,1) + bucket_name = get_new_bucket_name() + obj_name = get_random_string() #"single_line_csv.csv" + upload_object(bucket_name,obj_name,single_line_csv) + + for _ in range(100): + generate_s3select_where_clause(bucket_name,obj_name) + + +@pytest.mark.s3select +def test_generate_projection(): + + # create small csv file for testing the random expressions + single_line_csv = create_random_csv_object(1,1) + bucket_name = get_new_bucket_name() + obj_name = get_random_string() #"single_line_csv.csv" + upload_object(bucket_name,obj_name,single_line_csv) + + for _ in range(100): + generate_s3select_expression_projection(bucket_name,obj_name) + +def s3select_assert_result(a,b): + if type(a) == str: + a_strip = a.strip() + b_strip = b.strip() + if a=="" and b=="": + warnings.warn(UserWarning("{}".format("both results are empty, it may indicates a wrong input, please check the test input"))) + ## print the calling function that created the empty result. + stack = traceback.extract_stack(limit=2) + formatted_stack = traceback.format_list(stack)[0] + warnings.warn(UserWarning("{}".format(formatted_stack))) + return True + assert a_strip != "" + assert b_strip != "" + else: + if a=="" and b=="": + warnings.warn(UserWarning("{}".format("both results are empty, it may indicates a wrong input, please check the test input"))) + ## print the calling function that created the empty result. + stack = traceback.extract_stack(limit=2) + formatted_stack = traceback.format_list(stack)[0] + warnings.warn(UserWarning("{}".format(formatted_stack))) + return True + assert a != "" + assert b != "" + assert True + +def create_csv_object_for_datetime(rows,columns): + result = "" + for _ in range(rows): + row = "" + for _ in range(columns): + row = row + "{}{:02d}{:02d}T{:02d}{:02d}{:02d}Z,".format(random.randint(0,100)+1900,random.randint(1,12),random.randint(1,28),random.randint(0,23),random.randint(0,59),random.randint(0,59),) + result += row + "\n" + + return result + +def create_random_csv_object(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "" + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + for _ in range(columns): + row = row + "{}{}".format(random.randint(0,1000),col_delim) + result += row + record_delim + + return result + +def create_random_csv_object_string(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "" + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + for _ in range(columns): + if random.randint(0,9) == 5: + row = row + "{}{}".format(''.join(random.choice(string.ascii_letters) for m in range(10)) + "aeiou",col_delim) + else: + row = row + "{}{}".format(''.join("cbcd" + random.choice(string.ascii_letters) for m in range(10)) + "vwxyzzvwxyz" ,col_delim) + + result += row + record_delim + + return result + +def create_random_csv_object_trim(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "" + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + for _ in range(columns): + if random.randint(0,5) == 2: + row = row + "{}{}".format(''.join(" aeiou ") ,col_delim) + else: + row = row + "{}{}".format(''.join("abcd") ,col_delim) + + + + result += row + record_delim + + return result + +def create_random_csv_object_escape(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "" + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + for _ in range(columns): + if random.randint(0,9) == 5: + row = row + "{}{}".format(''.join("_ar") ,col_delim) + else: + row = row + "{}{}".format(''.join("aeio_") ,col_delim) + + result += row + record_delim + + return result + +def create_random_csv_object_null(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "" + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + for _ in range(columns): + if random.randint(0,5) == 2: + row = row + "{}{}".format(''.join("") ,col_delim) + else: + row = row + "{}{}".format(''.join("abc") ,col_delim) + + result += row + record_delim + + return result + +def create_random_json_object(rows,columns,col_delim=",",record_delim="\n",csv_schema=""): + result = "{\"root\" : [" + result += record_delim + if len(csv_schema)>0 : + result = csv_schema + record_delim + + for _ in range(rows): + row = "" + num = 0 + row += "{" + for _ in range(columns): + num += 1 + row = row + "\"c" + str(num) + "\"" + ": " "{}{}".format(random.randint(0,1000),col_delim) + row = row[:-1] + row += "}" + row += "," + result += row + record_delim + + result = result[:-2] + result += record_delim + result += "]" + "}" + + return result + +def csv_to_json(obj, field_split=",",row_split="\n",csv_schema=""): + result = "{\"root\" : [" + result += row_split + if len(csv_schema)>0 : + result = csv_schema + row_split + + for rec in obj.split(row_split): + row = "" + num = 0 + row += "{" + for col in rec.split(field_split): + if col == "": + break + num += 1 + row = row + "\"c" + str(num) + "\"" + ": " "{}{}".format(col,field_split) + row = row[:-1] + row += "}" + row += "," + result += row + row_split + + result = result[:-5] + result += row_split + result += "]" + "}" + + return result + +def upload_object(bucket_name,new_key,obj): + + client = get_client() + client.create_bucket(Bucket=bucket_name) + client.put_object(Bucket=bucket_name, Key=new_key, Body=obj) + + # validate uploaded object + c2 = get_client() + response = c2.get_object(Bucket=bucket_name, Key=new_key) + assert response['Body'].read().decode('utf-8') == obj, 's3select error[ downloaded object not equal to uploaded objecy' + +def run_s3select(bucket,key,query,column_delim=",",row_delim="\n",quot_char='"',esc_char='\\',csv_header_info="NONE", progress = False): + + s3 = get_client() + result = "" + result_status = {} + + try: + r = s3.select_object_content( + Bucket=bucket, + Key=key, + ExpressionType='SQL', + InputSerialization = {"CSV": {"RecordDelimiter" : row_delim, "FieldDelimiter" : column_delim,"QuoteEscapeCharacter": esc_char, "QuoteCharacter": quot_char, "FileHeaderInfo": csv_header_info}, "CompressionType": "NONE"}, + OutputSerialization = {"CSV": {}}, + Expression=query, + RequestProgress = {"Enabled": progress}) + + except ClientError as c: + result += str(c) + return result + + if progress == False: + + try: + for event in r['Payload']: + if 'Records' in event: + records = event['Records']['Payload'].decode('utf-8') + result += records + + except EventStreamError as c: + result = str(c) + return result + + else: + result = [] + max_progress_scanned = 0 + for event in r['Payload']: + if 'Records' in event: + records = event['Records'] + result.append(records.copy()) + if 'Progress' in event: + if(event['Progress']['Details']['BytesScanned'] > max_progress_scanned): + max_progress_scanned = event['Progress']['Details']['BytesScanned'] + result_status['Progress'] = event['Progress'] + + if 'Stats' in event: + result_status['Stats'] = event['Stats'] + if 'End' in event: + result_status['End'] = event['End'] + + + if progress == False: + return result + else: + return result,result_status + +def run_s3select_output(bucket,key,query, quot_field, op_column_delim = ",", op_row_delim = "\n", column_delim=",", op_quot_char = '"', op_esc_char = '\\', row_delim="\n",quot_char='"',esc_char='\\',csv_header_info="NONE"): + + s3 = get_client() + + r = s3.select_object_content( + Bucket=bucket, + Key=key, + ExpressionType='SQL', + InputSerialization = {"CSV": {"RecordDelimiter" : row_delim, "FieldDelimiter" : column_delim,"QuoteEscapeCharacter": esc_char, "QuoteCharacter": quot_char, "FileHeaderInfo": csv_header_info}, "CompressionType": "NONE"}, + OutputSerialization = {"CSV": {"RecordDelimiter" : op_row_delim, "FieldDelimiter" : op_column_delim, "QuoteCharacter" : op_quot_char, "QuoteEscapeCharacter" : op_esc_char, "QuoteFields" : quot_field}}, + Expression=query,) + + result = "" + for event in r['Payload']: + if 'Records' in event: + records = event['Records']['Payload'].decode('utf-8') + result += records + + return result + +def run_s3select_json(bucket,key,query, op_row_delim = "\n"): + + s3 = get_client() + + r = s3.select_object_content( + Bucket=bucket, + Key=key, + ExpressionType='SQL', + InputSerialization = {"JSON": {"Type": "DOCUMENT"}}, + OutputSerialization = {"JSON": {}}, + Expression=query,) + #Record delimiter optional in output serialization + + result = "" + for event in r['Payload']: + if 'Records' in event: + records = event['Records']['Payload'].decode('utf-8') + result += records + + return result + +def remove_xml_tags_from_result(obj): + result = "" + for rec in obj.split("\n"): + if(rec.find("Payload")>0 or rec.find("Records")>0): + continue + result += rec + "\n" # remove by split + + result_strip= result.strip() + x = bool(re.search("^failure.*$", result_strip)) + if x: + logging.info(result) + assert x == False + + return result + +def create_list_of_int(column_pos,obj,field_split=",",row_split="\n"): + + list_of_int = [] + for rec in obj.split(row_split): + col_num = 1 + if ( len(rec) == 0): + continue + for col in rec.split(field_split): + if (col_num == column_pos): + list_of_int.append(int(col)) + col_num+=1 + + return list_of_int + +@pytest.mark.s3select +def test_count_operation(): + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + num_of_rows = 1234 + obj_to_load = create_random_csv_object(num_of_rows,10) + upload_object(bucket_name,csv_obj_name,obj_to_load) + res = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object;") ).replace(",","") + + s3select_assert_result( num_of_rows, int( res )) + +@pytest.mark.s3select +def test_count_json_operation(): + json_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + num_of_rows = 1 + obj_to_load = create_random_json_object(num_of_rows,10) + upload_object(bucket_name,json_obj_name,obj_to_load) + res = remove_xml_tags_from_result(run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*];")) + s3select_assert_result( 1, int(res)) + + res = remove_xml_tags_from_result(run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root;")) + s3select_assert_result( 1, int(res)) + + obj_to_load = create_random_json_object(3,10) + upload_object(bucket_name,json_obj_name,obj_to_load) + res = remove_xml_tags_from_result(run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root;")) + s3select_assert_result( 3, int(res)) + +@pytest.mark.s3select +def test_json_column_sum_min_max(): + csv_obj = create_random_csv_object(10000,10) + + json_obj = csv_to_json(csv_obj); + + json_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,json_obj_name,json_obj) + + json_obj_name_2 = get_random_string() + bucket_name_2 = "testbuck2" + upload_object(bucket_name_2,json_obj_name_2,json_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select min(_1.c1) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 1 , csv_obj ) + res_target = min( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select min(_1.c4) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = min( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select avg(_1.c6) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 6 , csv_obj ) + res_target = float(sum(list_int ))/10000 + + s3select_assert_result( float(res_s3select), float(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select max(_1.c4) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = max( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select max(_1.c7) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 7 , csv_obj ) + res_target = max( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select sum(_1.c4) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = sum( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select sum(_1.c7) from s3object[*].root;") ).replace(",","") + list_int = create_list_of_int( 7 , csv_obj ) + res_target = sum( list_int ) + + s3select_assert_result( int(res_s3select) , int(res_target) ) + + # the following queries, validates on *random* input an *accurate* relation between condition result,sum operation and count operation. + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name_2,json_obj_name_2,"select count(0),sum(_1.c1),sum(_1.c2) from s3object[*].root where (_1.c1-_1.c2) = 2;" ) ) + count,sum1,sum2 = res_s3select.split(",") + + s3select_assert_result( int(count)*2 , int(sum1)-int(sum2 ) ) + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0),sum(_1.c1),sum(_1.c2) from s3object[*].root where (_1.c1-_1.c2) = 4;" ) ) + count,sum1,sum2 = res_s3select.split(",") + + s3select_assert_result( int(count)*4 , int(sum1)-int(sum2) ) + +@pytest.mark.s3select +def test_json_nullif_expressions(): + + json_obj = create_random_json_object(10000,10) + + json_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,json_obj_name,json_obj) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where nullif(_1.c1,_1.c2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where _1.c1 = _1.c2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select (nullif(_1.c1,_1.c2) is null) from s3object[*].root ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select (_1.c1 = _1.c2) from s3object[*].root ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where not nullif(_1.c1,_1.c2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where _1.c1 != _1.c2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select (nullif(_1.c1,_1.c2) is not null) from s3object[*].root ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select (_1.c1 != _1.c2) from s3object[*].root ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where nullif(_1.c1,_1.c2) = _1.c1 ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select_json(bucket_name,json_obj_name,"select count(0) from s3object[*].root where _1.c1 != _1.c2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + +@pytest.mark.s3select +def test_column_sum_min_max(): + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + csv_obj_name_2 = get_random_string() + bucket_name_2 = "testbuck2" + upload_object(bucket_name_2,csv_obj_name_2,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select min(int(_1)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 1 , csv_obj ) + res_target = min( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select min(int(_4)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = min( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select avg(int(_6)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 6 , csv_obj ) + res_target = float(sum(list_int ))/10000 + + s3select_assert_result( float(res_s3select), float(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select max(int(_4)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = max( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select max(int(_7)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 7 , csv_obj ) + res_target = max( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select sum(int(_4)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 4 , csv_obj ) + res_target = sum( list_int ) + + s3select_assert_result( int(res_s3select), int(res_target)) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select sum(int(_7)) from s3object;") ).replace(",","") + list_int = create_list_of_int( 7 , csv_obj ) + res_target = sum( list_int ) + + s3select_assert_result( int(res_s3select) , int(res_target) ) + + # the following queries, validates on *random* input an *accurate* relation between condition result,sum operation and count operation. + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name_2,csv_obj_name_2,"select count(0),sum(int(_1)),sum(int(_2)) from s3object where (int(_1)-int(_2)) = 2;" ) ) + count,sum1,sum2 = res_s3select.split(",") + + s3select_assert_result( int(count)*2 , int(sum1)-int(sum2 ) ) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0),sum(int(_1)),sum(int(_2)) from s3object where (int(_1)-int(_2)) = 4;" ) ) + count,sum1,sum2 = res_s3select.split(",") + + s3select_assert_result( int(count)*4 , int(sum1)-int(sum2) ) + +@pytest.mark.s3select +def test_nullif_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where nullif(_1,_2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 = _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (nullif(_1,_2) is null) from s3object ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (_1 = _2) from s3object ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where not nullif(_1,_2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (nullif(_1,_2) is not null) from s3object ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (_1 != _2) from s3object ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where nullif(_1,_2) = _1 ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + csv_obj = create_random_csv_object_null(10000,10) + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where nullif(_1,null) is null;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where _1 is null;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (nullif(_1,null) is null) from s3object;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select (_1 is null) from s3object;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + +@pytest.mark.s3select +def test_nulliftrue_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where (nullif(_1,_2) is null) = true ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 = _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where not (nullif(_1,_2) is null) = true ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where (nullif(_1,_2) = _1 = true) ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_nullif, res_s3select) + +@pytest.mark.s3select +def test_is_not_null_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_null = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where nullif(_1,_2) is not null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_null, res_s3select) + + res_s3select_null = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where (nullif(_1,_1) and _1 = _2) is not null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(*) from s3object where _1 != _2 ;") ).replace("\n","") + + s3select_assert_result( res_s3select_null, res_s3select) + +@pytest.mark.s3select +def test_lowerupper_expressions(): + + csv_obj = create_random_csv_object(1,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select lower("AB12cd$$") from s3object ;') ).replace("\n","") + + s3select_assert_result( res_s3select, "ab12cd$$") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select upper("ab12CD$$") from s3object ;') ).replace("\n","") + + s3select_assert_result( res_s3select, "AB12CD$$") + +@pytest.mark.s3select +def test_in_expressions(): + + # purpose of test: engine is process correctly several projections containing aggregation-functions + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) in(1);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = 1;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_1) in(1)) from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_1) = 1) from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) in(1,0);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = 1 or int(_1) = 0;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_1) in(1,0)) from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_1) = 1 or int(_1) = 0) from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2) in(1,0,2);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2) = 1 or int(_2) = 0 or int(_2) = 2;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_2) in(1,0,2)) from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_2) = 1 or int(_2) = 0 or int(_2) = 2) from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2)*2 in(int(_3)*2,int(_4)*3,int(_5)*5);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2)*2 = int(_3)*2 or int(_2)*2 = int(_4)*3 or int(_2)*2 = int(_5)*5;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_2)*2 in(int(_3)*2,int(_4)*3,int(_5)*5)) from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_2)*2 = int(_3)*2 or int(_2)*2 = int(_4)*3 or int(_2)*2 = int(_5)*5) from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where character_length(_1) = 2 and substring(_1,2,1) in ("3");')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where _1 like "_3";')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (character_length(_1) = 2 and substring(_1,2,1) in ("3")) from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_1 like "_3") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + +@pytest.mark.s3select +def test_true_false_in_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + ## 1,2 must exist in first/second column (to avoid empty results) + csv_obj = csv_obj + "1,2,,,,,,,,,,\n" + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where (int(_1) in(1)) = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = 1;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where (int(_1) in(1,0)) = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = 1 or int(_1) = 0;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where (int(_2) in(1,0,2)) = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2) = 1 or int(_2) = 0 or int(_2) = 2;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where (int(_2)*2 in(int(_3)*2,int(_4)*3,int(_5)*5)) = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from s3object where int(_2)*2 = int(_3)*2 or int(_2)*2 = int(_4)*3 or int(_2)*2 = int(_5)*5;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where (character_length(_1) = 2) = true and (substring(_1,2,1) in ("3")) = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where _1 like "_3";')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (int(_1) in (1,2,0)) as a1 from s3object where a1 = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select \"true\"from s3object where (int(_1) in (1,0,2)) ;')).replace("\n","") + + s3select_assert_result( res_s3select_in, res_s3select ) + +@pytest.mark.s3select +def test_like_expressions(): + + csv_obj = create_random_csv_object_string(1000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _1 like "%aeio%";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,11,4) = "aeio" ;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_1 like "%aeio%") from s3object ;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_1,11,4) = "aeio") from s3object ;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _1 like "cbcd%";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,1,4) = "cbcd";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _1 like "%aeio%" like;')).replace("\n","") + + find_like = res_s3select_like.find("UnsupportedSyntax") + + assert int(find_like) >= 0 + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_1 like "cbcd%") from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_1,1,4) = "cbcd") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _3 like "%y[y-z]";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_3,char_length(_3),1) between "y" and "z" and substring(_3,char_length(_3)-1,1) = "y";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_3 like "%y[y-z]") from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_3,char_length(_3),1) between "y" and "z" and substring(_3,char_length(_3)-1,1) = "y") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _2 like "%yz";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_2,char_length(_2),1) = "z" and substring(_2,char_length(_2)-1,1) = "y";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_2 like "%yz") from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_2,char_length(_2),1) = "z" and substring(_2,char_length(_2)-1,1) = "y") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _3 like "c%z";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_3,char_length(_3),1) = "z" and substring(_3,1,1) = "c";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_3 like "c%z") from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_3,char_length(_3),1) = "z" and substring(_3,1,1) = "c") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _2 like "%xy_";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_2,char_length(_2)-1,1) = "y" and substring(_2,char_length(_2)-2,1) = "x";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select (_2 like "%xy_") from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select (substring(_2,char_length(_2)-1,1) = "y" and substring(_2,char_length(_2)-2,1) = "x") from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + +@pytest.mark.s3select +def test_truefalselike_expressions(): + + csv_obj = create_random_csv_object_string(1000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_1 like "%aeio%") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,11,4) = "aeio" ;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_1 like "cbcd%") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,1,4) = "cbcd";')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_3 like "%y[y-z]") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where (substring(_3,char_length(_3),1) between "y" and "z") = true and (substring(_3,char_length(_3)-1,1) = "y") = true;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_2 like "%yz") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where (substring(_2,char_length(_2),1) = "z") = true and (substring(_2,char_length(_2)-1,1) = "y") = true;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_3 like "c%z") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where (substring(_3,char_length(_3),1) = "z") = true and (substring(_3,1,1) = "c") = true;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + + res_s3select_like = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where (_2 like "%xy_") = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where (substring(_2,char_length(_2)-1,1) = "y") = true and (substring(_2,char_length(_2)-2,1) = "x") = true;')).replace("\n","") + + s3select_assert_result( res_s3select_like, res_s3select ) + +@pytest.mark.s3select +def test_nullif_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where nullif(_1,_2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where _1 = _2 ;") ).replace("\n","") + + assert res_s3select_nullif == res_s3select + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where not nullif(_1,_2) is null ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where _1 != _2 ;") ).replace("\n","") + + assert res_s3select_nullif == res_s3select + + res_s3select_nullif = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where nullif(_1,_2) = _1 ;") ).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from stdin where _1 != _2 ;") ).replace("\n","") + + assert res_s3select_nullif == res_s3select + +@pytest.mark.s3select +def test_lowerupper_expressions(): + + csv_obj = create_random_csv_object(1,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select lower("AB12cd$$") from stdin ;') ).replace("\n","") + + assert res_s3select == "ab12cd$$" + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select upper("ab12CD$$") from stdin ;') ).replace("\n","") + + assert res_s3select == "AB12CD$$" + +@pytest.mark.s3select +def test_in_expressions(): + + # purpose of test: engine is process correctly several projections containing aggregation-functions + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where int(_1) in(1);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where int(_1) = 1;')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where int(_1) in(1,0);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where int(_1) = 1 or int(_1) = 0;')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from stdin where int(_2) in(1,0,2);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from stdin where int(_2) = 1 or int(_2) = 0 or int(_2) = 2;')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from stdin where int(_2)*2 in(int(_3)*2,int(_4)*3,int(_5)*5);')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_2) from stdin where int(_2)*2 = int(_3)*2 or int(_2)*2 = int(_4)*3 or int(_2)*2 = int(_5)*5;')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where character_length(_1) = 2 and substring(_1,2,1) in ("3");')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from stdin where _1 like "_3";')).replace("\n","") + + assert res_s3select_in == res_s3select + +@pytest.mark.s3select +def test_like_expressions(): + + csv_obj = create_random_csv_object_string(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _1 like "%aeio%";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_1,11,4) = "aeio" ;')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _1 like "cbcd%";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_1,1,4) = "cbcd";')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _3 like "%y[y-z]";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_3,character_length(_3),1) between "y" and "z" and substring(_3,character_length(_3)-1,1) = "y";')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _2 like "%yz";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_2,character_length(_2),1) = "z" and substring(_2,character_length(_2)-1,1) = "y";')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _3 like "c%z";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_3,character_length(_3),1) = "z" and substring(_3,1,1) = "c";')).replace("\n","") + + assert res_s3select_in == res_s3select + + res_s3select_in = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from stdin where _2 like "%xy_";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from stdin where substring(_2,character_length(_2)-1,1) = "y" and substring(_2,character_length(_2)-2,1) = "x";')).replace("\n","") + + assert res_s3select_in == res_s3select + + +@pytest.mark.s3select +def test_complex_expressions(): + + # purpose of test: engine is process correctly several projections containing aggregation-functions + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select min(int(_1)),max(int(_2)),min(int(_3))+1 from s3object;")).replace("\n","") + + min_1 = min ( create_list_of_int( 1 , csv_obj ) ) + max_2 = max ( create_list_of_int( 2 , csv_obj ) ) + min_3 = min ( create_list_of_int( 3 , csv_obj ) ) + 1 + + __res = "{},{},{}".format(min_1,max_2,min_3) + + # assert is according to radom-csv function + s3select_assert_result( res_s3select, __res ) + + # purpose of test that all where conditions create the same group of values, thus same result + res_s3select_substring = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select min(int(_2)),max(int(_2)) from s3object where substring(_2,1,1) = "1" and char_length(_2) = 3;')).replace("\n","") + + res_s3select_between_numbers = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select min(int(_2)),max(int(_2)) from s3object where int(_2)>=100 and int(_2)<200;')).replace("\n","") + + res_s3select_eq_modolu = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select min(int(_2)),max(int(_2)) from s3object where int(_2)/100 = 1 and character_length(_2) = 3;')).replace("\n","") + + s3select_assert_result( res_s3select_substring, res_s3select_between_numbers) + + s3select_assert_result( res_s3select_between_numbers, res_s3select_eq_modolu) + +@pytest.mark.s3select +def test_alias(): + + # purpose: test is comparing result of exactly the same queries , one with alias the other without. + # this test is setting alias on 3 projections, the third projection is using other projection alias, also the where clause is using aliases + # the test validate that where-clause and projections are executing aliases correctly, bare in mind that each alias has its own cache, + # and that cache need to be invalidate per new row. + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select int(_1) as a1, int(_2) as a2 , (a1+a2) as a3 from s3object where a3>100 and a3<300;") ).replace(",","") + + res_s3select_no_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select int(_1),int(_2),int(_1)+int(_2) from s3object where (int(_1)+int(_2))>100 and (int(_1)+int(_2))<300;") ).replace(",","") + + s3select_assert_result( res_s3select_alias, res_s3select_no_alias) + + +@pytest.mark.s3select +def test_alias_cyclic_refernce(): + + number_of_rows = 10000 + + # purpose of test is to validate the s3select-engine is able to detect a cyclic reference to alias. + csv_obj = create_random_csv_object(number_of_rows,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select int(_1) as a1,int(_2) as a2, a1+a4 as a3, a5+a1 as a4, int(_3)+a3 as a5 from s3object;") ) + + find_res = res_s3select_alias.find("number of calls exceed maximum size, probably a cyclic reference to alias") + + assert int(find_res) >= 0 + +@pytest.mark.s3select +def test_datetime(): + + # purpose of test is to validate date-time functionality is correct, + # by creating same groups with different functions (nested-calls) ,which later produce the same result + + csv_obj = create_csv_object_for_datetime(10000,1) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_date_time = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where extract(year from to_timestamp(_1)) > 1950 and extract(year from to_timestamp(_1)) < 1960;') ) + + res_s3select_substring = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where int(substring(_1,1,4))>1950 and int(substring(_1,1,4))<1960;') ) + + s3select_assert_result( res_s3select_date_time, res_s3select_substring) + + res_s3select_date_time_to_string = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select cast(to_string(to_timestamp(_1), \'x\') as int) from s3object;') ) + + res_s3select_date_time_extract = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select extract(timezone_hour from to_timestamp(_1)) from s3object;') ) + + s3select_assert_result( res_s3select_date_time_to_string, res_s3select_date_time_extract ) + + res_s3select_date_time_to_timestamp = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select extract(month from to_timestamp(_1)) from s3object where extract(month from to_timestamp(_1)) = 5;') ) + + res_s3select_substring = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select cast(substring(_1, 5, 2) as int) from s3object where _1 like \'____05%\';') ) + + s3select_assert_result( res_s3select_date_time_to_timestamp, res_s3select_substring) + +@pytest.mark.s3select +def test_true_false_datetime(): + + # purpose of test is to validate date-time functionality is correct, + # by creating same groups with different functions (nested-calls) ,which later produce the same result + + csv_obj = create_csv_object_for_datetime(10000,1) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_date_time = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where (extract(year from to_timestamp(_1)) > 1950) = true and (extract(year from to_timestamp(_1)) < 1960) = true;') ) + + res_s3select_substring = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where int(substring(_1,1,4))>1950 and int(substring(_1,1,4))<1960;') ) + + s3select_assert_result( res_s3select_date_time, res_s3select_substring) + + res_s3select_date_time = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where (date_diff(month,to_timestamp(_1),date_add(month,2,to_timestamp(_1)) ) = 2) = true;') ) + + res_s3select_count = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object;') ) + + s3select_assert_result( res_s3select_date_time, res_s3select_count) + + res_s3select_date_time = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where (date_diff(year,to_timestamp(_1),date_add(day, 366 ,to_timestamp(_1))) = 1) = true ;') ) + + s3select_assert_result( res_s3select_date_time, res_s3select_count) + + # validate that utcnow is integrate correctly with other date-time functions + res_s3select_date_time_utcnow = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(0) from s3object where (date_diff(hour,utcnow(),date_add(day,1,utcnow())) = 24) = true ;') ) + + s3select_assert_result( res_s3select_date_time_utcnow, res_s3select_count) + +@pytest.mark.s3select +def test_csv_parser(): + + # purpuse: test default csv values(, \n " \ ), return value may contain meta-char + # NOTE: should note that default meta-char for s3select are also for python, thus for one example double \ is mandatory + + csv_obj = r',first,,,second,third="c31,c32,c33",forth="1,2,3,4",fifth=my_string=\"any_value\" \, my_other_string=\"aaaa\,bbb\" ,' + "\n" + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + # return value contain comma{,} + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _6 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'third=c31,c32,c33') + + # return value contain comma{,} + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _7 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'forth=1,2,3,4') + + # return value contain comma{,}{"}, escape-rule{\} by-pass quote{"} , the escape{\} is removed. + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _8 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'fifth=my_string="any_value" , my_other_string="aaaa,bbb" ') + + # return NULL as first token + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _1 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'null') + + # return NULL in the middle of line + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _3 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'null') + + # return NULL in the middle of line (successive) + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _4 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'null') + + # return NULL at the end line + res_s3select_alias = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _9 from s3object;") ).replace("\n","") + s3select_assert_result( res_s3select_alias, 'null') + +@pytest.mark.s3select +def test_csv_definition(): + + number_of_rows = 10000 + + #create object with pipe-sign as field separator and tab as row delimiter. + csv_obj = create_random_csv_object(number_of_rows,10,"|","\t") + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + # purpose of tests is to parse correctly input with different csv defintions + res = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select count(0) from s3object;","|","\t") ).replace(",","") + + s3select_assert_result( number_of_rows, int(res)) + + # assert is according to radom-csv function + # purpose of test is validate that tokens are processed correctly + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select min(int(_1)),max(int(_2)),min(int(_3))+1 from s3object;","|","\t") ).replace("\n","") + + min_1 = min ( create_list_of_int( 1 , csv_obj , "|","\t") ) + max_2 = max ( create_list_of_int( 2 , csv_obj , "|","\t") ) + min_3 = min ( create_list_of_int( 3 , csv_obj , "|","\t") ) + 1 + + __res = "{},{},{}".format(min_1,max_2,min_3) + s3select_assert_result( res_s3select, __res ) + + +@pytest.mark.s3select +def test_schema_definition(): + + number_of_rows = 10000 + + # purpose of test is to validate functionality using csv header info + csv_obj = create_random_csv_object(number_of_rows,10,csv_schema="c1,c2,c3,c4,c5,c6,c7,c8,c9,c10") + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + # ignoring the schema on first line and retrieve using generic column number + res_ignore = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _1,_3 from s3object;",csv_header_info="IGNORE") ).replace("\n","") + + # using the scheme on first line, query is using the attach schema + res_use = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select c1,c3 from s3object;",csv_header_info="USE") ).replace("\n","") + # result of both queries should be the same + s3select_assert_result( res_ignore, res_use) + + # using column-name not exist in schema + res_multiple_defintion = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select c1,c10,int(c11) from s3object;",csv_header_info="USE") ).replace("\n","") + + assert ((res_multiple_defintion.find("alias {c11} or column not exist in schema")) >= 0) + + #find_processing_error = res_multiple_defintion.find("ProcessingTimeError") + assert ((res_multiple_defintion.find("ProcessingTimeError")) >= 0) + + # alias-name is identical to column-name + res_multiple_defintion = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select int(c1)+int(c2) as c4,c4 from s3object;",csv_header_info="USE") ).replace("\n","") + + assert ((res_multiple_defintion.find("multiple definition of column {c4} as schema-column and alias")) >= 0) + +@pytest.mark.s3select +def test_when_then_else_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select case when cast(_1 as int)>100 and cast(_1 as int)<200 then "(100-200)" when cast(_1 as int)>200 and cast(_1 as int)<300 then "(200-300)" else "NONE" end from s3object;') ).replace("\n","") + + count1 = res_s3select.count("(100-200)") + + count2 = res_s3select.count("(200-300)") + + count3 = res_s3select.count("NONE") + + res = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_1 as int)>100 and cast(_1 as int)<200 ;') ).replace("\n","") + + res1 = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_1 as int)>200 and cast(_1 as int)<300 ;') ).replace("\n","") + + res2 = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_1 as int)<=100 or cast(_1 as int)>=300 or cast(_1 as int)=200 ;') ).replace("\n","") + + s3select_assert_result( str(count1) , res) + + s3select_assert_result( str(count2) , res1) + + s3select_assert_result( str(count3) , res2) + +@pytest.mark.s3select +def test_coalesce_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where char_length(_3)>2 and char_length(_4)>2 and cast(substring(_3,1,2) as int) = cast(substring(_4,1,2) as int);') ).replace("\n","") + + res_null = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_3 as int)>99 and cast(_4 as int)>99 and coalesce(nullif(cast(substring(_3,1,2) as int),cast(substring(_4,1,2) as int)),7) = 7;' ) ).replace("\n","") + + s3select_assert_result( res_s3select, res_null) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select coalesce(nullif(_5,_5),nullif(_1,_1),_2) from s3object;') ).replace("\n","") + + res_coalesce = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select coalesce(_2) from s3object;') ).replace("\n","") + + s3select_assert_result( res_s3select, res_coalesce) + + +@pytest.mark.s3select +def test_cast_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_3 as int)>999;') ).replace("\n","") + + res = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where char_length(_3)>3;') ).replace("\n","") + + s3select_assert_result( res_s3select, res) + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(_3 as int)>99 and cast(_3 as int)<1000;') ).replace("\n","") + + res = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where char_length(_3)=3;') ).replace("\n","") + + s3select_assert_result( res_s3select, res) + +@pytest.mark.s3select +def test_version(): + + return + number_of_rows = 1 + + # purpose of test is to validate functionality using csv header info + csv_obj = create_random_csv_object(number_of_rows,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_version = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select version() from s3object;") ).replace("\n","") + + s3select_assert_result( res_version, "41.a," ) + +@pytest.mark.s3select +def test_trim_expressions(): + + csv_obj = create_random_csv_object_trim(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(_1) = "aeiou";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1 from 4 for 5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(both from _1) = "aeiou";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(trailing from _1) = " aeiou";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(leading from _1) = "aeiou ";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(trim(leading from _1)) = "aeiou";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + +@pytest.mark.s3select +def test_truefalse_trim_expressions(): + + csv_obj = create_random_csv_object_trim(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(_1) = "aeiou" = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1 from 4 for 5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(both from _1) = "aeiou" = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(trailing from _1) = " aeiou" = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(leading from _1) = "aeiou " = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + + res_s3select_trim = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where trim(trim(leading from _1)) = "aeiou" = true;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,4,5) = "aeiou";')).replace("\n","") + + s3select_assert_result( res_s3select_trim, res_s3select ) + +@pytest.mark.s3select +def test_escape_expressions(): + + csv_obj = create_random_csv_object_escape(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_escape = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _1 like "%_ar" escape "%";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,char_length(_1),1) = "r" and substring(_1,char_length(_1)-1,1) = "a" and substring(_1,char_length(_1)-2,1) = "_";')).replace("\n","") + + s3select_assert_result( res_s3select_escape, res_s3select ) + + res_s3select_escape = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where _1 like "%aeio$_" escape "$";')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where substring(_1,1,5) = "aeio_";')).replace("\n","") + + s3select_assert_result( res_s3select_escape, res_s3select ) + +@pytest.mark.s3select +def test_case_value_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_case = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select case cast(_1 as int) when cast(_2 as int) then "case_1_1" else "case_2_2" end from s3object;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select case when cast(_1 as int) = cast(_2 as int) then "case_1_1" else "case_2_2" end from s3object;')).replace("\n","") + + s3select_assert_result( res_s3select_case, res_s3select ) + +@pytest.mark.s3select +def test_bool_cast_expressions(): + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_cast = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select count(*) from s3object where cast(int(_1) as bool) = true ;')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name, 'select count(*) from s3object where cast(_1 as int) != 0 ;')).replace("\n","") + + s3select_assert_result( res_s3select_cast, res_s3select ) + +@pytest.mark.s3select +def test_progress_expressions(): + + csv_obj = create_random_csv_object(1000000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + obj_size = len(csv_obj.encode('utf-8')) + + result_status = {} + result_size = 0 + + res_s3select_response,result_status = run_s3select(bucket_name,csv_obj_name,"select sum(int(_1)) from s3object;",progress = True) + + for rec in res_s3select_response: + result_size += len(rec['Payload']) + + records_payload_size = result_size + + # To do: Validate bytes processed after supporting compressed data + s3select_assert_result(obj_size, result_status['Progress']['Details']['BytesScanned']) + s3select_assert_result(records_payload_size, result_status['Progress']['Details']['BytesReturned']) + + # stats response payload validation + s3select_assert_result(obj_size, result_status['Stats']['Details']['BytesScanned']) + s3select_assert_result(records_payload_size, result_status['Stats']['Details']['BytesReturned']) + + # end response + s3select_assert_result({}, result_status['End']) + +@pytest.mark.s3select +def test_output_serial_expressions(): + return # TODO fix test + + csv_obj = create_random_csv_object(10000,10) + + csv_obj_name = get_random_string() + bucket_name = get_new_bucket_name() + + upload_object(bucket_name,csv_obj_name,csv_obj) + + res_s3select_1 = remove_xml_tags_from_result( run_s3select_output(bucket_name,csv_obj_name,"select _1, _2 from s3object where nullif(_1,_2) is null ;", "ALWAYS") ).replace("\n",",").replace(",","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,"select _1, _2 from s3object where _1 = _2 ;") ).replace("\n",",") + + res_s3select_list = res_s3select.split(',') + + res_s3select_list.pop() + + res_s3select_final = (''.join('"' + item + '"' for item in res_s3select_list)) + + s3select_assert_result( '""'+res_s3select_1+'""', res_s3select_final) + + + res_s3select_in = remove_xml_tags_from_result( run_s3select_output(bucket_name,csv_obj_name,'select int(_1) from s3object where (int(_1) in(int(_2)));', "ASNEEDED", '$', '#')).replace("\n","#") ## TODO why \n appears in output? + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = int(_2);')).replace("\n","#") + + res_s3select_list = res_s3select.split('#') + + res_s3select_list.pop() + + res_s3select_final = (''.join(item + '#' for item in res_s3select_list)) + + + s3select_assert_result(res_s3select_in , res_s3select_final ) + + + res_s3select_quot = remove_xml_tags_from_result( run_s3select_output(bucket_name,csv_obj_name,'select int(_1) from s3object where (int(_1) in(int(_2)));', "ALWAYS", '$', '#')).replace("\n","") + + res_s3select = remove_xml_tags_from_result( run_s3select(bucket_name,csv_obj_name,'select int(_1) from s3object where int(_1) = int(_2);')).replace("\n","#") + res_s3select_list = res_s3select.split('#') + + res_s3select_list.pop() + + res_s3select_final = (''.join('"' + item + '"' + '#' for item in res_s3select_list)) + + s3select_assert_result( '""#'+res_s3select_quot+'""#', res_s3select_final ) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_sns.py b/src/test/rgw/s3-tests/s3tests/functional/test_sns.py new file mode 100644 index 00000000000..c63b68411ab --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_sns.py @@ -0,0 +1,186 @@ +import json +import pytest +from botocore.exceptions import ClientError +from . import ( + configfile, + get_iam_path_prefix, + get_iam_root_client, + get_iam_alt_root_client, + get_new_bucket_name, + get_prefix, + make_iam_name, + nuke_prefixed_buckets, +) +from .iam import iam_root, iam_alt_root +from .utils import assert_raises, _get_status_and_error_code + +def get_new_topic_name(): + return get_new_bucket_name() + +def nuke_topics(client, prefix): + p = client.get_paginator('list_topics') + for response in p.paginate(): + for topic in response['Topics']: + arn = topic['TopicArn'] + if prefix not in arn: + pass + try: + client.delete_topic(TopicArn=arn) + except: + pass + +@pytest.fixture +def sns(iam_root): + client = get_iam_root_client(service_name='sns') + yield client + nuke_topics(client, get_prefix()) + +@pytest.fixture +def sns_alt(iam_alt_root): + client = get_iam_alt_root_client(service_name='sns') + yield client + nuke_topics(client, get_prefix()) + +@pytest.fixture +def s3(iam_root): + # clear region_name to work around Invalid region: region was not a valid DNS name. + client = get_iam_root_client(service_name='s3', region_name=None) + yield client + nuke_prefixed_buckets(get_prefix(), client) + +@pytest.fixture +def s3_alt(iam_alt_root): + # clear region_name to work around Invalid region: region was not a valid DNS name. + client = get_iam_alt_root_client(service_name='s3', region_name=None) + yield client + nuke_prefixed_buckets(get_prefix(), client) + + +@pytest.mark.iam_account +@pytest.mark.sns +def test_account_topic(sns): + name = get_new_topic_name() + + response = sns.create_topic(Name=name) + arn = response['TopicArn'] + assert arn.startswith('arn:aws:sns:') + assert arn.endswith(f':{name}') + + response = sns.list_topics() + assert arn in [p['TopicArn'] for p in response['Topics']] + + sns.set_topic_attributes(TopicArn=arn, AttributeName='Policy', AttributeValue='') + + response = sns.get_topic_attributes(TopicArn=arn) + assert 'Attributes' in response + + sns.delete_topic(TopicArn=arn) + + response = sns.list_topics() + assert arn not in [p['TopicArn'] for p in response['Topics']] + + with pytest.raises(sns.exceptions.NotFoundException): + sns.get_topic_attributes(TopicArn=arn) + sns.delete_topic(TopicArn=arn) + +@pytest.mark.iam_account +@pytest.mark.sns +def test_cross_account_topic(sns, sns_alt): + name = get_new_topic_name() + arn = sns.create_topic(Name=name)['TopicArn'] + + # not authorized to any alt user apis + with pytest.raises(sns.exceptions.AuthorizationErrorException): + sns_alt.get_topic_attributes(TopicArn=arn) + with pytest.raises(sns.exceptions.AuthorizationErrorException): + sns_alt.set_topic_attributes(TopicArn=arn, AttributeName='Policy', AttributeValue='') + with pytest.raises(sns.exceptions.AuthorizationErrorException): + sns_alt.delete_topic(TopicArn=arn) + + # delete returns success + sns.delete_topic(TopicArn=arn) + + response = sns_alt.list_topics() + assert arn not in [p['TopicArn'] for p in response['Topics']] + +@pytest.mark.iam_account +@pytest.mark.sns +def test_account_user_policy_list_topics(iam_root): + path = get_iam_path_prefix() + user_name = make_iam_name('User') + + user = iam_root.create_user(UserName=user_name, Path=path)['User'] + user_arn = user['Arn'] + key = iam_root.create_access_key(UserName=user_name)['AccessKey'] + + sns = get_iam_root_client(service_name='sns', + aws_access_key_id=key['AccessKeyId'], + aws_secret_access_key=key['SecretAccessKey']) + + # expect AccessDenied for lack of identity policy + e = assert_raises(ClientError, sns.list_topics) + + iam_root.attach_user_policy(UserName=user_name, PolicyArn='arn:aws:iam::aws:policy/AmazonSNSReadOnlyAccess') + + sns.list_topics() + +@pytest.mark.iam_account +@pytest.mark.sns +def test_account_topic_publish(sns, s3): + name = get_new_topic_name() + + response = sns.create_topic(Name=name) + topic_arn = response['TopicArn'] + + bucket = get_new_bucket_name() + s3.create_bucket(Bucket=bucket) + + config = {'TopicConfigurations': [{ + 'Id': 'id', + 'TopicArn': topic_arn, + 'Events': [ 's3:ObjectCreated:*' ], + }]} + s3.put_bucket_notification_configuration( + Bucket=bucket, NotificationConfiguration=config) + +@pytest.mark.iam_account +@pytest.mark.iam_cross_account +@pytest.mark.sns +def test_cross_account_topic_publish(sns, s3_alt, iam_alt_root): + name = get_new_topic_name() + + response = sns.create_topic(Name=name) + topic_arn = response['TopicArn'] + + bucket = get_new_bucket_name() + s3_alt.create_bucket(Bucket=bucket) + + config = {'TopicConfigurations': [{ + 'Id': 'id', + 'TopicArn': topic_arn, + 'Events': [ 's3:ObjectCreated:*' ], + }]} + + # expect AccessDenies because no resource policy allows cross-account access + e = assert_raises(ClientError, s3_alt.put_bucket_notification_configuration, + Bucket=bucket, NotificationConfiguration=config) + status, error_code = _get_status_and_error_code(e.response) + assert status == 403 + assert error_code == 'AccessDenied' + + # add topic policy to allow the alt user + alt_principal = iam_alt_root.get_user()['User']['Arn'] + policy = json.dumps({ + 'Version': '2012-10-17', + 'Statement': [{ + 'Effect': 'Allow', + 'Principal': {'AWS': alt_principal}, + 'Action': 'sns:Publish', + 'Resource': topic_arn + }] + }) + sns.set_topic_attributes(TopicArn=topic_arn, AttributeName='Policy', + AttributeValue=policy) + + s3_alt.put_bucket_notification_configuration( + Bucket=bucket, NotificationConfiguration=config) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_sts.py b/src/test/rgw/s3-tests/s3tests/functional/test_sts.py new file mode 100644 index 00000000000..485ab401247 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_sts.py @@ -0,0 +1,2073 @@ +import boto3 +import botocore.session +from botocore.exceptions import ClientError +from botocore.exceptions import ParamValidationError +import pytest +import isodate +import email.utils +import datetime +import threading +import re +import pytz +from collections import OrderedDict +import requests +import json +import base64 +import hmac +import hashlib +import xml.etree.ElementTree as ET +import time +import operator +import os +import string +import random +import socket +import ssl +import logging +from collections import namedtuple + +from email.header import decode_header + +from . import( + configfile, + setup_teardown, + get_iam_client, + get_sts_client, + get_client, + get_alt_user_id, + get_config_endpoint, + get_new_bucket_name, + get_parameter_name, + get_main_aws_access_key, + get_main_aws_secret_key, + get_thumbprint, + get_aud, + get_token, + get_realm_name, + check_webidentity, + get_iam_access_key, + get_iam_secret_key, + get_sub, + get_azp, + get_user_token + ) + +log = logging.getLogger(__name__) + +def create_role(iam_client,path,rolename,policy_document,description,sessionduration,permissionboundary,tag_list=None): + role_err=None + role_response = None + if rolename is None: + rolename=get_parameter_name() + if tag_list is None: + tag_list = [] + try: + role_response = iam_client.create_role(Path=path,RoleName=rolename,AssumeRolePolicyDocument=policy_document,Tags=tag_list) + except ClientError as e: + role_err = e.response['Code'] + return (role_err,role_response,rolename) + +def put_role_policy(iam_client,rolename,policyname,role_policy): + role_err=None + role_response = None + if policyname is None: + policyname=get_parameter_name() + try: + role_response = iam_client.put_role_policy(RoleName=rolename,PolicyName=policyname,PolicyDocument=role_policy) + except ClientError as e: + role_err = e.response['Code'] + return (role_err,role_response) + +def put_user_policy(iam_client,username,policyname,policy_document): + role_err=None + role_response = None + if policyname is None: + policyname=get_parameter_name() + try: + role_response = iam_client.put_user_policy(UserName=username,PolicyName=policyname,PolicyDocument=policy_document) + except ClientError as e: + role_err = e.response['Code'] + return (role_err,role_response,policyname) + +def get_s3_client_using_iam_creds(): + iam_access_key = get_iam_access_key() + iam_secret_key = get_iam_secret_key() + default_endpoint = get_config_endpoint() + + s3_client_iam_creds = boto3.client('s3', + aws_access_key_id = iam_access_key, + aws_secret_access_key = iam_secret_key, + endpoint_url=default_endpoint, + region_name='', + ) + + return s3_client_iam_creds + +def create_oidc_provider(iam_client, url, clientidlist, thumbprintlist): + oidc_arn = None + oidc_error = None + clientids = [] + if clientidlist is None: + clientidlist=clientids + try: + oidc_response = iam_client.create_open_id_connect_provider( + Url=url, + ClientIDList=clientidlist, + ThumbprintList=thumbprintlist, + ) + oidc_arn = oidc_response['OpenIDConnectProviderArn'] + print (oidc_arn) + except ClientError as e: + oidc_error = e.response['Code'] + print (oidc_error) + try: + oidc_error = None + print (url) + if url.startswith('http://'): + url = url[len('http://'):] + elif url.startswith('https://'): + url = url[len('https://'):] + elif url.startswith('www.'): + url = url[len('www.'):] + oidc_arn = 'arn:aws:iam:::oidc-provider/{}'.format(url) + print (url) + print (oidc_arn) + oidc_response = iam_client.get_open_id_connect_provider(OpenIDConnectProviderArn=oidc_arn) + except ClientError as e: + oidc_arn = None + return (oidc_arn, oidc_error) + +def get_s3_resource_using_iam_creds(): + iam_access_key = get_iam_access_key() + iam_secret_key = get_iam_secret_key() + default_endpoint = get_config_endpoint() + + s3_res_iam_creds = boto3.resource('s3', + aws_access_key_id = iam_access_key, + aws_secret_access_key = iam_secret_key, + endpoint_url=default_endpoint, + region_name='', + ) + + return s3_res_iam_creds + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_get_session_token(): + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + + user_policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Deny\",\"Action\":\"s3:*\",\"Resource\":[\"*\"],\"Condition\":{\"BoolIfExists\":{\"sts:authentication\":\"false\"}}},{\"Effect\":\"Allow\",\"Action\":\"sts:GetSessionToken\",\"Resource\":\"*\",\"Condition\":{\"BoolIfExists\":{\"sts:authentication\":\"false\"}}}]}" + (resp_err,resp,policy_name)=put_user_policy(iam_client,sts_user_id,None,user_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + response=sts_client.get_session_token() + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client=boto3.client('s3', + aws_access_key_id = response['Credentials']['AccessKeyId'], + aws_secret_access_key = response['Credentials']['SecretAccessKey'], + aws_session_token = response['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + finish=s3_client.delete_bucket(Bucket=bucket_name) + finally: # clean up user policy even if create_bucket/delete_bucket fails + iam_client.delete_user_policy(UserName=sts_user_id,PolicyName=policy_name) + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_assume_role_allow(): + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"arn:aws:iam:::user/"+sts_user_id+"\"]},\"Action\":[\"sts:AssumeRole\"]}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + if role_response: + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + else: + assert False, role_error + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_assume_role_deny(): + s3bucket_error=None + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"arn:aws:iam:::user/"+sts_user_id+"\"]},\"Action\":[\"sts:AssumeRole\"]}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + if role_response: + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + else: + assert False, role_error + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Deny\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + except ClientError as e: + s3bucket_error = e.response.get("Error", {}).get("Code") + assert s3bucket_error == 'AccessDenied' + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_assume_role_creds_expiry(): + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"arn:aws:iam:::user/"+sts_user_id+"\"]},\"Action\":[\"sts:AssumeRole\"]}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + if role_response: + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + else: + assert False, role_error + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,DurationSeconds=900) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + time.sleep(900) + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + except ClientError as e: + s3bucket_error = e.response.get("Error", {}).get("Code") + http_status_code = e.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + assert s3bucket_error == 'ExpiredToken' + assert http_status_code == 400 + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_assume_role_deny_head_nonexistent(): + # create a bucket with the normal s3 client + bucket_name = get_new_bucket_name() + get_client().create_bucket(Bucket=bucket_name) + + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + + policy_document = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/'+sts_user_id+'"]},"Action":["sts:AssumeRole"]}]}' + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + if role_response: + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name + else: + assert False, role_error + + # allow GetObject but deny ListBucket + role_policy = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":"s3:GetObject","Principal":"*","Resource":"arn:aws:s3:::*"}}' + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='') + status=200 + try: + s3_client.head_object(Bucket=bucket_name, Key='nonexistent') + except ClientError as e: + status = e.response['ResponseMetadata']['HTTPStatusCode'] + assert status == 403 + +@pytest.mark.test_of_sts +@pytest.mark.fails_on_dbstore +def test_assume_role_allow_head_nonexistent(): + # create a bucket with the normal s3 client + bucket_name = get_new_bucket_name() + get_client().create_bucket(Bucket=bucket_name) + + iam_client=get_iam_client() + sts_client=get_sts_client() + sts_user_id=get_alt_user_id() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + + policy_document = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/'+sts_user_id+'"]},"Action":["sts:AssumeRole"]}]}' + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + if role_response: + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name + else: + assert False, role_error + + # allow GetObject and ListBucket + role_policy = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Action":["s3:GetObject","s3:ListBucket"],"Principal":"*","Resource":"arn:aws:s3:::*"}}' + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='') + status=200 + try: + s3_client.head_object(Bucket=bucket_name, Key='nonexistent') + except ClientError as e: + status = e.response['ResponseMetadata']['HTTPStatusCode'] + assert status == 404 + + +@pytest.mark.webidentity_test +@pytest.mark.token_claims_trust_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + if response: + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + else: + assert False, role_err + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +''' +@pytest.mark.webidentity_test +def test_assume_role_with_web_identity_invalid_webtoken(): + resp_error=None + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp="" + try: + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken='abcdef') + except InvalidIdentityTokenException as e: + log.debug('{}'.format(resp)) + log.debug('{}'.format(e.response.get("Error", {}).get("Code"))) + log.debug('{}'.format(e)) + resp_error = e.response.get("Error", {}).get("Code") + assert resp_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) +''' + +####################### +# Session Policy Tests +####################### + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_on_different_buckets(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"arn:aws:s3:::test2\",\"arn:aws:s3:::test2/*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_name_1 = 'test1' + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name_1) + except ClientError as e: + s3bucket_error = e.response.get("Error", {}).get("Code") + assert s3bucket_error == 'AccessDenied' + + bucket_name_2 = 'test2' + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name_2) + except ClientError as e: + s3bucket_error = e.response.get("Error", {}).get("Code") + assert s3bucket_error == 'AccessDenied' + + bucket_body = 'please-write-something' + #body.encode(encoding='utf_8') + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'NoSuchBucket' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_on_same_bucket(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_put_obj_denial(): + check_webidentity() + iam_client=get_iam_client() + iam_access_key=get_iam_access_key() + iam_secret_key=get_iam_secret_key() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_swapping_role_policy_and_session_policy(): + check_webidentity() + iam_client=get_iam_client() + iam_access_key=get_iam_access_key() + iam_secret_key=get_iam_secret_key() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_different_op_permissions(): + check_webidentity() + iam_client=get_iam_client() + iam_access_key=get_iam_access_key() + iam_secret_key=get_iam_secret_key() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_with_deny_effect(): + check_webidentity() + iam_client=get_iam_client() + iam_access_key=get_iam_access_key() + iam_secret_key=get_iam_secret_key() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Deny\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_check_with_deny_on_same_op(): + check_webidentity() + iam_client=get_iam_client() + iam_access_key=get_iam_access_key() + iam_secret_key=get_iam_secret_key() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy_new = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy_new) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client_iam_creds = get_s3_client_using_iam_creds() + + bucket_name_1 = 'test1' + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Deny\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_bucket_policy_role_arn(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3client_iamcreds = get_s3_client_using_iam_creds() + bucket_name_1 = 'test1' + s3bucket = s3client_iamcreds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + resource1 = "arn:aws:s3:::" + bucket_name_1 + resource2 = "arn:aws:s3:::" + bucket_name_1 + "/*" + rolearn = "arn:aws:iam:::role/" + general_role_name + bucket_policy = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "{}".format(rolearn)}, + "Action": ["s3:GetObject","s3:PutObject"], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + s3client_iamcreds.put_bucket_policy(Bucket=bucket_name_1, Policy=bucket_policy) + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + try: + obj = s3_client.get_object(Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3object_error = e.response.get("Error", {}).get("Code") + assert s3object_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_bucket_policy_session_arn(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3client_iamcreds = get_s3_client_using_iam_creds() + bucket_name_1 = 'test1' + s3bucket = s3client_iamcreds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + resource1 = "arn:aws:s3:::" + bucket_name_1 + resource2 = "arn:aws:s3:::" + bucket_name_1 + "/*" + rolesessionarn = "arn:aws:iam:::assumed-role/" + general_role_name + "/" + role_session_name + bucket_policy = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "{}".format(rolesessionarn)}, + "Action": ["s3:GetObject","s3:PutObject"], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + s3client_iamcreds.put_bucket_policy(Bucket=bucket_name_1, Policy=bucket_policy) + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + + s3_get_obj = s3_client.get_object(Bucket=bucket_name_1, Key="test-1.txt") + assert s3_get_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_copy_object(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3client_iamcreds = get_s3_client_using_iam_creds() + bucket_name_1 = 'test1' + s3bucket = s3client_iamcreds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + resource1 = "arn:aws:s3:::" + bucket_name_1 + resource2 = "arn:aws:s3:::" + bucket_name_1 + "/*" + rolesessionarn = "arn:aws:iam:::assumed-role/" + general_role_name + "/" + role_session_name + print (rolesessionarn) + bucket_policy = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "{}".format(rolesessionarn)}, + "Action": ["s3:GetObject","s3:PutObject"], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + s3client_iamcreds.put_bucket_policy(Bucket=bucket_name_1, Policy=bucket_policy) + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + copy_source = { + 'Bucket': bucket_name_1, + 'Key': 'test-1.txt' + } + + s3_client.copy(copy_source, bucket_name_1, "test-2.txt") + + s3_get_obj = s3_client.get_object(Bucket=bucket_name_1, Key="test-2.txt") + assert s3_get_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_no_bucket_role_policy(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + s3client_iamcreds = get_s3_client_using_iam_creds() + bucket_name_1 = 'test1' + s3bucket = s3client_iamcreds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\",\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3putobj_error = e.response.get("Error", {}).get("Code") + assert s3putobj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.session_policy +@pytest.mark.fails_on_dbstore +def test_session_policy_bucket_policy_deny(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + aud=get_aud() + token=get_token() + realm=get_realm_name() + + url = 'http://localhost:8080/auth/realms/{}'.format(realm) + thumbprintlist = [thumbprint] + (oidc_arn,oidc_error) = create_oidc_provider(iam_client, url, None, thumbprintlist) + if oidc_error is not None: + raise RuntimeError('Unable to create/get openid connect provider {}'.format(oidc_error)) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_arn+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":app_id\":\""+aud+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":[\"*\"]}}" + + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3client_iamcreds = get_s3_client_using_iam_creds() + bucket_name_1 = 'test1' + s3bucket = s3client_iamcreds.create_bucket(Bucket=bucket_name_1) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + resource1 = "arn:aws:s3:::" + bucket_name_1 + resource2 = "arn:aws:s3:::" + bucket_name_1 + "/*" + rolesessionarn = "arn:aws:iam:::assumed-role/" + general_role_name + "/" + role_session_name + bucket_policy = json.dumps( + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Deny", + "Principal": {"AWS": "{}".format(rolesessionarn)}, + "Action": ["s3:GetObject","s3:PutObject"], + "Resource": [ + "{}".format(resource1), + "{}".format(resource2) + ] + }] + }) + s3client_iamcreds.put_bucket_policy(Bucket=bucket_name_1, Policy=bucket_policy) + session_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":[\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::test1\",\"arn:aws:s3:::test1/*\"]}}" + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token,Policy=session_policy) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_body = 'this is a test file' + + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name_1, Key="test-1.txt") + except ClientError as e: + s3putobj_error = e.response.get("Error", {}).get("Code") + assert s3putobj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_arn + ) + +@pytest.mark.webidentity_test +@pytest.mark.token_claims_trust_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_with_sub(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + sub=get_sub() + token=get_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":sub\":\""+sub+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.token_claims_trust_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_with_azp(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + azp=get_azp() + token=get_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\"],\"Condition\":{\"StringEquals\":{\"localhost:8080/auth/realms/"+realm+":azp\":\""+azp+"\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_request_tag_trust_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_with_request_tag(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\"}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_principal_tag_role_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_with_principal_tag(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"aws:PrincipalTag/Department\":\"Engineering\"}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_principal_tag_role_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_for_all_values(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"ForAllValues:StringEquals\":{\"aws:PrincipalTag/Department\":[\"Engineering\",\"Marketing\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_principal_tag_role_policy_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_for_all_values_deny(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + #ForAllValues: The condition returns true if every key value in the request matches at least one value in the policy + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"ForAllValues:StringEquals\":{\"aws:PrincipalTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + try: + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + except ClientError as e: + s3bucket_error = e.response.get("Error", {}).get("Code") + assert s3bucket_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_tag_keys_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_tag_keys_trust_policy(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:TagKeys\":\"Department\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"ForAnyValue:StringEquals\":{\"aws:PrincipalTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_tag_keys_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_tag_keys_role_policy(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"aws:TagKeys\":[\"Department\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + bucket_name = get_new_bucket_name() + s3bucket = s3_client.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + bkt = s3_client.delete_bucket(Bucket=bucket_name) + assert bkt['ResponseMetadata']['HTTPStatusCode'] == 204 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_resource_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_resource_tag(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'Engineering'},{'Key':'Department', 'Value': 'Marketing'}]}) + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_resource_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_resource_tag_deny(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_resource_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_wrong_resource_tag_deny(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'WrongResourcetag'}]}) + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + try: + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key="test-1.txt") + except ClientError as e: + s3_put_obj_error = e.response.get("Error", {}).get("Code") + assert s3_put_obj_error == 'AccessDenied' + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_resource_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_resource_tag_princ_tag(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'Engineering'}]}) + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"${aws:PrincipalTag/Department}\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + tags = 'Department=Engineering&Department=Marketing' + key = "test-1.txt" + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key=key, Tagging=tags) + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_get_obj = s3_client.get_object(Bucket=bucket_name, Key=key) + assert s3_get_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_resource_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_resource_tag_copy_obj(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + #create two buckets and add same tags to both + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'Engineering'}]}) + + copy_bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=copy_bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(copy_bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'Engineering'}]}) + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"aws:RequestTag/Department\":\"Engineering\"}}}]}" + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"${aws:PrincipalTag/Department}\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + tags = 'Department=Engineering' + key = "test-1.txt" + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key=key, Tagging=tags) + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + #copy to same bucket + copy_source = { + 'Bucket': bucket_name, + 'Key': 'test-1.txt' + } + + s3_client.copy(copy_source, bucket_name, "test-2.txt") + + s3_get_obj = s3_client.get_object(Bucket=bucket_name, Key="test-2.txt") + assert s3_get_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + #copy to another bucket + copy_source = { + 'Bucket': bucket_name, + 'Key': 'test-1.txt' + } + + s3_client.copy(copy_source, copy_bucket_name, "test-1.txt") + + s3_get_obj = s3_client.get_object(Bucket=copy_bucket_name, Key="test-1.txt") + assert s3_get_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) + +@pytest.mark.webidentity_test +@pytest.mark.abac_test +@pytest.mark.token_role_tags_test +@pytest.mark.fails_on_dbstore +def test_assume_role_with_web_identity_role_resource_tag(): + check_webidentity() + iam_client=get_iam_client() + sts_client=get_sts_client() + default_endpoint=get_config_endpoint() + role_session_name=get_parameter_name() + thumbprint=get_thumbprint() + user_token=get_user_token() + realm=get_realm_name() + + s3_res_iam_creds = get_s3_resource_using_iam_creds() + + s3_client_iam_creds = s3_res_iam_creds.meta.client + + bucket_name = get_new_bucket_name() + s3bucket = s3_client_iam_creds.create_bucket(Bucket=bucket_name) + assert s3bucket['ResponseMetadata']['HTTPStatusCode'] == 200 + + bucket_tagging = s3_res_iam_creds.BucketTagging(bucket_name) + Set_Tag = bucket_tagging.put(Tagging={'TagSet':[{'Key':'Department', 'Value': 'Engineering'},{'Key':'Department', 'Value': 'Marketing'}]}) + + oidc_response = iam_client.create_open_id_connect_provider( + Url='http://localhost:8080/auth/realms/{}'.format(realm), + ThumbprintList=[ + thumbprint, + ], + ) + + #iam:ResourceTag refers to the tag attached to role, hence the role is allowed to be assumed only when it has a tag matching the policy. + policy_document = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":[\""+oidc_response["OpenIDConnectProviderArn"]+"\"]},\"Action\":[\"sts:AssumeRoleWithWebIdentity\",\"sts:TagSession\"],\"Condition\":{\"StringEquals\":{\"iam:ResourceTag/Department\":\"Engineering\"}}}]}" + tags_list = [ + {'Key':'Department','Value':'Engineering'}, + {'Key':'Department','Value':'Marketing'} + ] + + (role_error,role_response,general_role_name)=create_role(iam_client,'/',None,policy_document,None,None,None,tags_list) + assert role_response['Role']['Arn'] == 'arn:aws:iam:::role/'+general_role_name+'' + + role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":{\"Effect\":\"Allow\",\"Action\":\"s3:*\",\"Resource\":\"arn:aws:s3:::*\",\"Condition\":{\"StringEquals\":{\"s3:ResourceTag/Department\":[\"Engineering\"]}}}}" + (role_err,response)=put_role_policy(iam_client,general_role_name,None,role_policy) + assert response['ResponseMetadata']['HTTPStatusCode'] == 200 + + resp=sts_client.assume_role_with_web_identity(RoleArn=role_response['Role']['Arn'],RoleSessionName=role_session_name,WebIdentityToken=user_token) + assert resp['ResponseMetadata']['HTTPStatusCode'] == 200 + + s3_client = boto3.client('s3', + aws_access_key_id = resp['Credentials']['AccessKeyId'], + aws_secret_access_key = resp['Credentials']['SecretAccessKey'], + aws_session_token = resp['Credentials']['SessionToken'], + endpoint_url=default_endpoint, + region_name='', + ) + + bucket_body = 'this is a test file' + s3_put_obj = s3_client.put_object(Body=bucket_body, Bucket=bucket_name, Key="test-1.txt") + assert s3_put_obj['ResponseMetadata']['HTTPStatusCode'] == 200 + + oidc_remove=iam_client.delete_open_id_connect_provider( + OpenIDConnectProviderArn=oidc_response["OpenIDConnectProviderArn"] + ) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_utils.py b/src/test/rgw/s3-tests/s3tests/functional/test_utils.py new file mode 100644 index 00000000000..c0dd3980670 --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/test_utils.py @@ -0,0 +1,9 @@ +from . import utils + +def test_generate(): + FIVE_MB = 5 * 1024 * 1024 + assert len(''.join(utils.generate_random(0))) == 0 + assert len(''.join(utils.generate_random(1))) == 1 + assert len(''.join(utils.generate_random(FIVE_MB - 1))) == FIVE_MB - 1 + assert len(''.join(utils.generate_random(FIVE_MB))) == FIVE_MB + assert len(''.join(utils.generate_random(FIVE_MB + 1))) == FIVE_MB + 1 diff --git a/src/test/rgw/s3-tests/s3tests/functional/utils.py b/src/test/rgw/s3-tests/s3tests/functional/utils.py new file mode 100644 index 00000000000..ab84c1651ba --- /dev/null +++ b/src/test/rgw/s3-tests/s3tests/functional/utils.py @@ -0,0 +1,47 @@ +import random +import requests +import string +import time + +def assert_raises(excClass, callableObj, *args, **kwargs): + """ + Like unittest.TestCase.assertRaises, but returns the exception. + """ + try: + callableObj(*args, **kwargs) + except excClass as e: + return e + else: + if hasattr(excClass, '__name__'): + excName = excClass.__name__ + else: + excName = str(excClass) + raise AssertionError("%s not raised" % excName) + +def generate_random(size, part_size=5*1024*1024): + """ + Generate the specified number random data. + (actually each MB is a repetition of the first KB) + """ + chunk = 1024 + allowed = string.ascii_letters + for x in range(0, size, part_size): + strpart = ''.join([allowed[random.randint(0, len(allowed) - 1)] for _ in range(chunk)]) + s = '' + left = size - x + this_part_size = min(left, part_size) + for y in range(this_part_size // chunk): + s = s + strpart + s = s + strpart[:(this_part_size % chunk)] + yield s + if (x == size): + return + +def _get_status(response): + status = response['ResponseMetadata']['HTTPStatusCode'] + return status + +def _get_status_and_error_code(response): + status = response['ResponseMetadata']['HTTPStatusCode'] + error_code = response['Error']['Code'] + return status, error_code diff --git a/src/test/rgw/s3-tests/setup.py b/src/test/rgw/s3-tests/setup.py new file mode 100644 index 00000000000..d3c7e7bf2ec --- /dev/null +++ b/src/test/rgw/s3-tests/setup.py @@ -0,0 +1,22 @@ +#!/usr/bin/python +from setuptools import setup, find_packages + +setup( + name='s3tests', + version='0.0.1', + packages=find_packages(), + + author='Tommi Virtanen', + author_email='tommi.virtanen@dreamhost.com', + description='Unofficial Amazon AWS S3 compatibility tests', + license='MIT', + keywords='s3 web testing', + + install_requires=[ + 'boto3 >=1.0.0', + 'PyYAML', + 'munch >=2.0.0', + 'gevent >=1.0', + 'isodate >=0.4.4', + ], + ) diff --git a/src/test/rgw/s3-tests/tox.ini b/src/test/rgw/s3-tests/tox.ini new file mode 100644 index 00000000000..e4a30e5b3f0 --- /dev/null +++ b/src/test/rgw/s3-tests/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py + +[testenv] +deps = -rrequirements.txt +passenv = + S3TEST_CONF + S3_USE_SIGV4 +commands = pytest {posargs} From d0c88cb53c6848e9bcd7c4ea90f21f1aa6912242 Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Mon, 29 Sep 2025 17:11:09 -0400 Subject: [PATCH 358/596] qa/rgw: Run s3-tests from within the Ceph repo Signed-off-by: Adam C. Emerson --- qa/rgw/s3tests-branch.yaml | 4 --- .../rgw/lifecycle/s3tests-branch.yaml | 1 - .../rgw/multifs/s3tests-branch.yaml | 1 - .../crimson-rados/rgw/sts/s3tests-branch.yaml | 1 - .../rgw/bucket-logging/s3tests-branch.yaml | 1 - .../rgw/cloud-transition/s3tests-branch.yaml | 1 - qa/suites/rgw/crypt/s3tests-branch.yaml | 1 - qa/suites/rgw/dbstore/s3tests-branch.yaml | 1 - qa/suites/rgw/lifecycle/s3tests-branch.yaml | 1 - qa/suites/rgw/multifs/s3tests-branch.yaml | 1 - qa/suites/rgw/s3control/s3tests-branch.yaml | 1 - qa/suites/rgw/sts/s3tests-branch.yaml | 1 - qa/suites/rgw/tempest/s3tests-branch.yaml | 1 - qa/suites/rgw/thrash/s3tests-branch.yaml | 1 - qa/suites/rgw/verify/s3tests-branch.yaml | 1 - qa/suites/rgw/website/s3tests-branch.yaml | 1 - qa/tasks/s3tests.py | 26 +++++++++---------- 17 files changed, 12 insertions(+), 33 deletions(-) delete mode 100644 qa/rgw/s3tests-branch.yaml delete mode 120000 qa/suites/crimson-rados/rgw/lifecycle/s3tests-branch.yaml delete mode 120000 qa/suites/crimson-rados/rgw/multifs/s3tests-branch.yaml delete mode 120000 qa/suites/crimson-rados/rgw/sts/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/bucket-logging/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/cloud-transition/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/crypt/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/dbstore/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/lifecycle/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/multifs/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/s3control/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/sts/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/tempest/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/thrash/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/verify/s3tests-branch.yaml delete mode 120000 qa/suites/rgw/website/s3tests-branch.yaml diff --git a/qa/rgw/s3tests-branch.yaml b/qa/rgw/s3tests-branch.yaml deleted file mode 100644 index 8710ce35893..00000000000 --- a/qa/rgw/s3tests-branch.yaml +++ /dev/null @@ -1,4 +0,0 @@ -overrides: - s3tests: - force-branch: ceph-master - # git_remote: https://github.com/ceph/ diff --git a/qa/suites/crimson-rados/rgw/lifecycle/s3tests-branch.yaml b/qa/suites/crimson-rados/rgw/lifecycle/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/crimson-rados/rgw/lifecycle/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/crimson-rados/rgw/multifs/s3tests-branch.yaml b/qa/suites/crimson-rados/rgw/multifs/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/crimson-rados/rgw/multifs/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/crimson-rados/rgw/sts/s3tests-branch.yaml b/qa/suites/crimson-rados/rgw/sts/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/crimson-rados/rgw/sts/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/bucket-logging/s3tests-branch.yaml b/qa/suites/rgw/bucket-logging/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/bucket-logging/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/cloud-transition/s3tests-branch.yaml b/qa/suites/rgw/cloud-transition/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/cloud-transition/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/crypt/s3tests-branch.yaml b/qa/suites/rgw/crypt/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/crypt/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/dbstore/s3tests-branch.yaml b/qa/suites/rgw/dbstore/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/dbstore/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/lifecycle/s3tests-branch.yaml b/qa/suites/rgw/lifecycle/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/lifecycle/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/multifs/s3tests-branch.yaml b/qa/suites/rgw/multifs/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/multifs/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/s3control/s3tests-branch.yaml b/qa/suites/rgw/s3control/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/s3control/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/sts/s3tests-branch.yaml b/qa/suites/rgw/sts/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/sts/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/tempest/s3tests-branch.yaml b/qa/suites/rgw/tempest/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/tempest/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/thrash/s3tests-branch.yaml b/qa/suites/rgw/thrash/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/thrash/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/verify/s3tests-branch.yaml b/qa/suites/rgw/verify/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/verify/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/suites/rgw/website/s3tests-branch.yaml b/qa/suites/rgw/website/s3tests-branch.yaml deleted file mode 120000 index bdcaca48ae0..00000000000 --- a/qa/suites/rgw/website/s3tests-branch.yaml +++ /dev/null @@ -1 +0,0 @@ -.qa/rgw/s3tests-branch.yaml \ No newline at end of file diff --git a/qa/tasks/s3tests.py b/qa/tasks/s3tests.py index 3a9db1d543c..45e36f2bc22 100644 --- a/qa/tasks/s3tests.py +++ b/qa/tasks/s3tests.py @@ -32,31 +32,29 @@ def download(ctx, config): assert isinstance(config, dict) log.info('Downloading s3-tests...') testdir = teuthology.get_testdir(ctx) + s3tests_branch = ctx.config.get('suite_branch') or ctx.config.get('branch') + s3tests_repo = ctx.config.get('suite_repo') or teuth_config.get_ceph_qa_suite_git_url() + s3tests_sha1 = ctx.config.get('suite_sha1') + if not s3tests_branch: + raise ValueError( + "Could not determine what branch to use for ceph suite.") for (client, client_config) in config.items(): - s3tests_branch = client_config.get('force-branch', None) - if not s3tests_branch: - raise ValueError( - "Could not determine what branch to use for s3-tests. Please add 'force-branch: {s3-tests branch name}' to the .yaml config for this s3tests task.") - log.info("Using branch '%s' for s3tests", s3tests_branch) - sha1 = client_config.get('sha1') - git_remote = client_config.get('git_remote', teuth_config.ceph_git_base_url) ctx.cluster.only(client).run( args=[ - 'git', 'clone', - '-b', s3tests_branch, - git_remote + 's3-tests.git', - '{tdir}/s3-tests-{client}'.format(tdir=testdir, client=client), + 'git', 'clone', '-b', s3tests_branch, + s3tests_repo, '{tdir}/s3-tests-{client}'.format(tdir=testdir, client=client), ], ) - if sha1 is not None: + if s3tests_sha1 is not None: ctx.cluster.only(client).run( args=[ 'cd', '{tdir}/s3-tests-{client}'.format(tdir=testdir, client=client), run.Raw('&&'), - 'git', 'reset', '--hard', sha1, + 'git', 'reset', '--hard', s3tests_sha1, ], ) + if client_config.get('boto3_extensions'): ctx.cluster.only(client).run( args=['mkdir', @@ -464,7 +462,7 @@ def run_tests(ctx, config): client_config = client_config or {} (remote,) = ctx.cluster.only(client).remotes.keys() args = [ - 'cd', '{tdir}/s3-tests-{client}'.format(tdir=testdir, client=client), run.Raw('&&'), + 'cd', '{tdir}/s3-tests-{client}/src/test/rgw/s3-tests'.format(tdir=testdir, client=client), run.Raw('&&'), 'S3TEST_CONF={tdir}/archive/s3-tests.{client}.conf'.format(tdir=testdir, client=client), 'BOTO_CONFIG={tdir}/boto-{client}.cfg'.format(tdir=testdir, client=client) ] From 5bc34f8ed84aa2f205ccfe04fff9e3ec5ced8727 Mon Sep 17 00:00:00 2001 From: "Adam C. Emerson" Date: Wed, 17 Jun 2026 14:33:45 -0400 Subject: [PATCH 359/596] qa/rgw: Remove 'force-branch' from s3tests configs Signed-off-by: Adam C. Emerson --- qa/suites/smoke/basic/tasks/test/rgw_ec_s3tests.yaml | 1 - qa/suites/smoke/basic/tasks/test/rgw_s3tests.yaml | 1 - qa/suites/teuthology/rgw/tasks/s3tests-fastcgi.yaml | 1 - qa/suites/teuthology/rgw/tasks/s3tests-fcgi.yaml | 1 - qa/suites/teuthology/rgw/tasks/s3tests.yaml | 1 - 5 files changed, 5 deletions(-) diff --git a/qa/suites/smoke/basic/tasks/test/rgw_ec_s3tests.yaml b/qa/suites/smoke/basic/tasks/test/rgw_ec_s3tests.yaml index 8f824838cb1..3214fd9009b 100644 --- a/qa/suites/smoke/basic/tasks/test/rgw_ec_s3tests.yaml +++ b/qa/suites/smoke/basic/tasks/test/rgw_ec_s3tests.yaml @@ -10,7 +10,6 @@ tasks: - tox: [client.0] - s3tests: client.0: - force-branch: ceph-master rgw_server: client.0 overrides: ceph: diff --git a/qa/suites/smoke/basic/tasks/test/rgw_s3tests.yaml b/qa/suites/smoke/basic/tasks/test/rgw_s3tests.yaml index 65f7ec16b51..e8c4296dc86 100644 --- a/qa/suites/smoke/basic/tasks/test/rgw_s3tests.yaml +++ b/qa/suites/smoke/basic/tasks/test/rgw_s3tests.yaml @@ -7,7 +7,6 @@ tasks: - tox: [client.0] - s3tests: client.0: - force-branch: ceph-master unit_test_scan: True rgw_server: client.0 overrides: diff --git a/qa/suites/teuthology/rgw/tasks/s3tests-fastcgi.yaml b/qa/suites/teuthology/rgw/tasks/s3tests-fastcgi.yaml index d76121fad10..08ceb25c357 100644 --- a/qa/suites/teuthology/rgw/tasks/s3tests-fastcgi.yaml +++ b/qa/suites/teuthology/rgw/tasks/s3tests-fastcgi.yaml @@ -11,7 +11,6 @@ tasks: - s3tests: client.0: rgw_server: client.0 - force-branch: ceph-master overrides: ceph: fs: xfs diff --git a/qa/suites/teuthology/rgw/tasks/s3tests-fcgi.yaml b/qa/suites/teuthology/rgw/tasks/s3tests-fcgi.yaml index 8228501ca4b..1746b5bbe73 100644 --- a/qa/suites/teuthology/rgw/tasks/s3tests-fcgi.yaml +++ b/qa/suites/teuthology/rgw/tasks/s3tests-fcgi.yaml @@ -12,7 +12,6 @@ tasks: - s3tests: client.0: rgw_server: client.0 - force-branch: ceph-master overrides: ceph: fs: xfs diff --git a/qa/suites/teuthology/rgw/tasks/s3tests.yaml b/qa/suites/teuthology/rgw/tasks/s3tests.yaml index ee8e9d5b5dc..beaffbbde45 100644 --- a/qa/suites/teuthology/rgw/tasks/s3tests.yaml +++ b/qa/suites/teuthology/rgw/tasks/s3tests.yaml @@ -11,7 +11,6 @@ tasks: - s3tests: client.0: rgw_server: client.0 - force-branch: ceph-master overrides: ceph: fs: xfs From 640bea831a921bd10d119a75ef973a14b4bed20f Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 23 Jun 2026 01:38:54 +0800 Subject: [PATCH 360/596] test/encoding: disable LeakSanitizer in readable.sh and check-generated.sh readable.sh forks two short-lived ceph-dencoder processes per corpus object(tens of thousands of processes overall). With ASAN's LeakSanitizer enabled each process runs a whole-heap leak check at exit that dominates runtime (~10s vs ~1s per process, measured on riscv64). This test only validates encode/decode behaviour, not leaks, so disable leak detection while keeping ASAN's other checks active. check-generated.sh forks ceph-dencoder the same way, so apply the same fix there. Signed-off-by: Sun Yuechi --- src/test/encoding/check-generated.sh | 6 ++++++ src/test/encoding/readable.sh | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/test/encoding/check-generated.sh b/src/test/encoding/check-generated.sh index c297d415273..42e70eed463 100755 --- a/src/test/encoding/check-generated.sh +++ b/src/test/encoding/check-generated.sh @@ -4,6 +4,12 @@ set -e source $(dirname $0)/../detect-build-env-vars.sh source $CEPH_ROOT/qa/standalone/ceph-helpers.sh +if ldd $(command -v ceph-dencoder) 2>/dev/null | grep -q libasan; then + # Per-object leak checks dominate runtime here (~10s vs ~1s each on riscv64) + # and this test only checks encode/decode, so disable them; keep other checks. + export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}detect_leaks=0" +fi + dir=$1 test_selected_type() { diff --git a/src/test/encoding/readable.sh b/src/test/encoding/readable.sh index dcf53d2c8ae..607657355bf 100755 --- a/src/test/encoding/readable.sh +++ b/src/test/encoding/readable.sh @@ -34,6 +34,10 @@ if ldd $(command -v $CEPH_DENCODER) 2>/dev/null | grep -q libasan; then else echo "ASAN build detected but 'setarch -R' is unavailable; running ceph-dencoder without ASLR disable" fi + + # Per-object leak checks dominate runtime here (~10s vs ~1s each on riscv64) + # and this test only checks encode/decode, so disable them; keep other checks. + export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}detect_leaks=0" fi myversion=$($CEPH_DENCODER version) From 9dd7fd0b0a78871f7f71c89a061490f3ffc97605 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 15:43:28 +0800 Subject: [PATCH 361/596] mgr/dashboard: skip the table when an nvmeof cli result has no columns The dashboard leaves prettytable unpinned. prettytable commit 2574492 ("Apply some Pylint rules (PLR)", #436) rewrote _stringify_row()'s row_height as `max(_get_size(c)[1] for c in row)`, which raises ValueError("max() iterable argument is empty") on a row with no cells. The change is undocumented and shipped in 3.18.0; get_string() trips on it when a table has a row but no columns. AnnotatedDataTextOutputFormatter builds such a table for an empty result, or one whose only field is status or error_message, so NvmeofCLICommand.call() returns -EINVAL and the command fails. This broke run-tox-mgr-dashboard-py3 once the tox virtualenv picked up prettytable 3.18.0. Return an empty string when there are no columns instead of formatting a degenerate table. Fixes: https://tracker.ceph.com/issues/77589 Signed-off-by: Kefu Chai --- src/pybind/mgr/dashboard/services/nvmeof_cli.py | 4 ++++ .../mgr/dashboard/tests/test_nvmeof_cli.py | 16 ++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/pybind/mgr/dashboard/services/nvmeof_cli.py b/src/pybind/mgr/dashboard/services/nvmeof_cli.py index a56e5cde968..5f454c3c24e 100644 --- a/src/pybind/mgr/dashboard/services/nvmeof_cli.py +++ b/src/pybind/mgr/dashboard/services/nvmeof_cli.py @@ -204,6 +204,8 @@ class AnnotatedDataTextOutputFormatter(OutputFormatter): def _get_list_text_output(self, data): columns = list(dict.fromkeys([key for obj in data for key in obj.keys()])) + if not columns: + return '' table = self._create_table(columns) for d in data: table.add_row(self._get_row(columns, d)) @@ -211,6 +213,8 @@ class AnnotatedDataTextOutputFormatter(OutputFormatter): def _get_object_text_output(self, data): columns = [k for k in data.keys() if k not in ["status", "error_message"]] + if not columns: + return '' table = self._create_table(columns) table.add_row(self._get_row(columns, data)) return table.get_string() diff --git a/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py b/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py index bc75950059a..690103969df 100644 --- a/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py +++ b/src/pybind/mgr/dashboard/tests/test_nvmeof_cli.py @@ -117,13 +117,7 @@ class TestNvmeofCLICommand: result = NvmeofCLICommand.COMMANDS[sample_command].call(MagicMock(), {}) assert isinstance(result, HandleCommandResult) assert result.retval == 0 - assert result.stdout == ( - "++\n" - "||\n" - "++\n" - "\n" - "++" - ) + assert result.stdout == '' assert result.stderr == '' base_call_return_none_mock.assert_called_once() @@ -788,13 +782,7 @@ class TestNvmeofCLICommandSuccessMessage: ) assert res.retval == 0 assert res.stderr == "" - assert res.stdout == ( - "++\n" - "||\n" - "++\n" - "\n" - "++" - ) + assert res.stdout == '' del NvmeofCLICommand.COMMANDS[test_cmd] assert test_cmd not in NvmeofCLICommand.COMMANDS From 51d8c5c489ba3e664209fb3316f8d6e03e257e28 Mon Sep 17 00:00:00 2001 From: Alex Ainscow Date: Tue, 9 Jun 2026 15:45:25 +0100 Subject: [PATCH 362/596] osd/ECTransaction: fix truncate+write planning for EC shard sizes There are multiple problems fixed here, which are caused by operations which perform a truncate-then-write in a single transaction. NOTE: The only known scenario for these operations are CLS-sparsify operations. I recommend backporting these changes to Tentacle, however, as they are regressions in the RADOS API which can lead to data corruption. PROBLEM: Cache not invalidated correctly: If projected_size >= orig_size, the invalidate_cache flag is not set in the plan This means there is potentially data in the RMW extent cache, which may cause data corruption in subsequent writes (although not the write being made). No actual use case for this operation is known, so it may not be as serious as it sounds. FIX: Invalidate cache on any truncate or "delete_first" PROBLEM: Parity writes permitted with truncates: Currently parity delta writes do not support operations which may have truncates. However, if the object ended up the same size as pre-truncate, a parity delta write may have been attemtped. This may lead to data corruption. FIX: Block parity writes in this case. NOTE: It is not worth the development effort to support PDW in this scenario, as performance benefit would be minimal overall. PROBLEM: RMW Reads can occur beyond first truncate point. Such reads reflect the pre-truncate data (which is incorrect) and be preserved, even if the invalidate cache flag is set (since the cache is invalidated before the reads). This can lead to similar corruption as the earlier "cache not invalidated" FIX: trim the read to the lower truncate size. PROBLEM: When performing a truncate, the parity shards may need to be updated to reflect truncates on other shards. These truncate writes in the plan were overriding, rather than adding to, other writes in the op. The result was a potentially truncated coding shard. This leads to assertions on reads. FIX: Replace = with insert() PROBLEM: Some shards not set to correct size on truncate-then-write If projected_size is smaller or equal to the original size, then the code which attempts to correctly size a shard will not run. However if an operation performs a truncate then a partial write and does not write to a particular shard AND the shard ends up smaller, then this can lead to an incorrectly sized shard. This leads to assertion on reads. FIX: Execute the shard-resize code on all truncates. AI assistance was mainly used to write unit test. However, I cannot rule out a contribution to the simple fixes found in this commit, so out of caution, I place the Assisted-by tag. Fixes: https://tracker.ceph.com/issues/77276 Signed-off-by: Alex Ainscow Assisted-by: IBM-Bob:ClaudeSonnet/GPT --- src/osd/ECTransaction.cc | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/osd/ECTransaction.cc b/src/osd/ECTransaction.cc index 8368a219040..ed6551b711a 100644 --- a/src/osd/ECTransaction.cc +++ b/src/osd/ECTransaction.cc @@ -149,7 +149,7 @@ ECTransaction::WritePlanObj::WritePlanObj( * 2. ALL delete operations (do NOT use is_delete() here!!!) * 3. Truncates that reduce size. */ - invalidates_cache = op.has_source(&source) || op.delete_first || projected_size < orig_size; + invalidates_cache = op.has_source(&source) || op.delete_first || (op.truncate && op.truncate->first < orig_size); op.buffer_updates.to_interval_set(unaligned_ro_writes); @@ -215,7 +215,7 @@ ECTransaction::WritePlanObj::WritePlanObj( /* Here we decide if we want to do a conventional write or a parity delta write. */ if (sinfo.supports_parity_delta_writes() && !object_in_cache && - orig_size == projected_size && !reads.empty()) { + orig_size == projected_size && !reads.empty() && !op.truncate) { shard_id_set read_shards = reads.get_shard_id_set(); shard_id_set pdw_read_shards = pdw_reads.get_shard_id_set(); @@ -267,6 +267,15 @@ ECTransaction::WritePlanObj::WritePlanObj( * read the existing data on the partial stripe. */ if (op.truncate && op.truncate->first < orig_size) { + if (to_read) { + ECUtil::shard_extent_set_t truncate_mask(sinfo.get_k_plus_m()); + sinfo.ro_range_to_shard_extent_set(0, op.truncate->first, truncate_mask); + + to_read->intersection_of(truncate_mask); + if (to_read->empty()) { + to_read = std::nullopt; + } + } ECUtil::shard_extent_set_t truncate_read(sinfo.get_k_plus_m()); uint64_t prev_stripe = sinfo.ro_offset_to_prev_stripe_ro_offset(op.truncate->first); uint64_t next_align = ECUtil::align_next(op.truncate->first); @@ -294,7 +303,7 @@ ECTransaction::WritePlanObj::WritePlanObj( // We only need to update the parity buffer for the write for (auto && shard : sinfo.get_parity_shards()) { - will_write[shard] = truncate_write; + will_write[shard].insert(truncate_write); } } } @@ -1000,7 +1009,7 @@ void ECTransaction::Generate::appends_and_clone_ranges() { ECUtil::shard_extent_set_t cloneable_range(sinfo.get_k_plus_m()); sinfo.ro_size_to_read_mask(clone_max, cloneable_range); - if (plan.orig_size < plan.projected_size) { + if (op.delete_first || op.truncate || plan.orig_size < plan.projected_size) { ECUtil::shard_extent_set_t projected_cloneable_range(sinfo.get_k_plus_m()); sinfo.ro_size_to_read_mask(plan.projected_size,projected_cloneable_range); From 6062ff880bccf402c29710cbae8df4d760629b6a Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 23 Jun 2026 10:18:29 +0100 Subject: [PATCH 363/596] doc/install: restore cephadm Octopus version floor note Reverts the removal of the minimum version bullet per review feedback. The Rook version line and ceph-ansible block removals are kept as-is. --- doc/install/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/install/index.rst b/doc/install/index.rst index 5be6dda1ffa..5a94577a316 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -12,6 +12,7 @@ Recommended methods :ref:`Cephadm ` is a tool that can be used to install and manage a Ceph cluster. +* cephadm supports only Octopus and newer releases. * cephadm is fully integrated with the orchestration API and fully supports the CLI and dashboard features that are used to manage cluster deployment. * cephadm requires container support (in the form of Podman or Docker) and From 72265c88005a172dd75e8b0a7749a646178a688e Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 23 Jun 2026 10:34:33 +0100 Subject: [PATCH 364/596] doc/rados/operations: refine cache-tiering deprecation note per review Reduce the note to point existing users to the removal procedure, add a ref target for the removal section, and rephrase the dm-cache mention to reflect community adoption without implying official endorsement. --- doc/rados/operations/cache-tiering.rst | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/rados/operations/cache-tiering.rst b/doc/rados/operations/cache-tiering.rst index a1f8e49a933..b3df1c3d418 100644 --- a/doc/rados/operations/cache-tiering.rst +++ b/doc/rados/operations/cache-tiering.rst @@ -7,11 +7,11 @@ notice. **Do not deploy new cache tiers.** Migrate existing cache tier deployments as soon as possible. -.. note:: The following documentation is retained for reference for existing - deployments only. For new deployments, use erasure-coded pools or fast - storage pools directly. As a community-supported block-level caching - alternative, consider ``dm-cache`` (the Linux kernel's device-mapper cache - target). +.. note:: This documentation is retained for existing deployments only. + If you need to remove a cache tier, see :ref:`cache-tiering-removal`. + Some community members have adopted ``dm-cache`` (the Linux kernel's + device-mapper cache target) as a block-level caching alternative, though + this is not an officially supported or endorsed configuration. A cache tier provides Ceph clients with better I/O performance for a subset of the data stored in a backing storage tier. Cache tiering involves creating a @@ -452,10 +452,12 @@ For example, to evict objects after 30 minutes, execute the following: ceph osd pool set hot-storage cache_min_evict_age 1800 +.. _cache-tiering-removal: + Removing a Cache Tier ===================== -Removing a cache tier differs depending on whether it is a writeback +Removing a cache tier differs depending on whether it is a writeback cache or a read-only cache. From b2ecec6b3a0b854c393232ed785c3953f87f7201 Mon Sep 17 00:00:00 2001 From: Emmanuel Ameh Date: Tue, 23 Jun 2026 11:23:14 +0100 Subject: [PATCH 365/596] doc/cephfs: clarify deprecated commands and remove obsolete upgrade path Several CephFS pages described deprecated commands and features without clear status or replacements, and one page documented an upgrade path that predates every supported release. * cephfs-mirroring: correct the peer_add deprecation note to reference the actual replacement command, peer_bootstrap create, and link the Bootstrap Peers section * kernel-features, experimental-features: state that inline data has been deprecated since the Octopus release and that enabling it triggers a health warning * upgrading: remove the "Upgrading pre-Firefly file systems past Jewel" section; the tmap_upgrade command it referenced was removed in Kraken Fixes: https://tracker.ceph.com/issues/77188 Signed-off-by: Emmanuel Ameh --- doc/cephfs/cephfs-mirroring.rst | 5 ++-- doc/cephfs/experimental-features.rst | 4 ++-- doc/cephfs/kernel-features.rst | 6 ++--- doc/cephfs/upgrading.rst | 34 ---------------------------- 4 files changed, 8 insertions(+), 41 deletions(-) diff --git a/doc/cephfs/cephfs-mirroring.rst b/doc/cephfs/cephfs-mirroring.rst index cbd51081dfc..850c8fb972e 100644 --- a/doc/cephfs/cephfs-mirroring.rst +++ b/doc/cephfs/cephfs-mirroring.rst @@ -173,8 +173,9 @@ and the user key. However, bootstrapping a peer is the recommended way to add a peer. .. note:: Only a single peer is currently supported. - The ``peer_add`` command is deprecated and will be removed in a future release. - Use the ``peer_bootstrap`` command instead. + The ``peer_add`` command is deprecated and will be removed in a future + release. Use the ``peer_bootstrap create`` command instead. See the + :ref:`Bootstrap Peers` section. To remove a peer, run a command of the following form: diff --git a/doc/cephfs/experimental-features.rst b/doc/cephfs/experimental-features.rst index f5f7ebba672..00a53aaba59 100644 --- a/doc/cephfs/experimental-features.rst +++ b/doc/cephfs/experimental-features.rst @@ -24,8 +24,8 @@ failures within it are unlikely to make non-inlined data inaccessible. Inline data has always been off by default and requires setting the ``inline_data`` flag. -Inline data has been declared deprecated for the Octopus release, and will -likely be removed altogether in a future release. +Inline data has been deprecated since the Octopus release. Enabling it triggers +a health warning, and the feature will be removed altogether in a future release. Mantle: Programmable Metadata Load Balancer ------------------------------------------- diff --git a/doc/cephfs/kernel-features.rst b/doc/cephfs/kernel-features.rst index 6dc66374d6f..83c0a2f244b 100644 --- a/doc/cephfs/kernel-features.rst +++ b/doc/cephfs/kernel-features.rst @@ -8,9 +8,9 @@ in the kernel driver. Inline data ----------- -Inline data was introduced by the Firefly release. This feature is being -deprecated in mainline CephFS, and may be removed from a future kernel -release. +Inline data was introduced by the Firefly release. This feature has been +deprecated since the Octopus release: enabling it triggers a health warning, +and it will be removed in a future release. Linux kernel clients >= 3.19 can read inline data and convert existing inline data to RADOS objects when file data is modified. At present, diff --git a/doc/cephfs/upgrading.rst b/doc/cephfs/upgrading.rst index 0396bd5c1a3..7ca59200d23 100644 --- a/doc/cephfs/upgrading.rst +++ b/doc/cephfs/upgrading.rst @@ -54,37 +54,3 @@ command. Older versions of Ceph require you to stop these daemons manually. ceph fs set max_mds ceph fs set allow_standby_replay - -Upgrading pre-Firefly file systems past Jewel -============================================= - -.. tip:: - - This advice only applies to users with file systems - created using versions of Ceph older than *Firefly* (0.80). - Users creating new file systems may disregard this advice. - -Pre-firefly versions of Ceph used a now-deprecated format -for storing CephFS directory objects, called TMAPs. Support -for reading these in RADOS will be removed after the Jewel -release of Ceph, so for upgrading CephFS users it is important -to ensure that any old directory objects have been converted. - -After installing Jewel on all your MDS and OSD servers, and restarting -the services, run the following command: - -:: - - cephfs-data-scan tmap_upgrade - -This only needs to be run once, and it is not necessary to -stop any other services while it runs. The command may take some -time to execute, as it iterates over all objects in your metadata -pool. It is safe to continue using your file system as normal while -it executes. If the command aborts for any reason, it is safe -to simply run it again. - -If you are upgrading a pre-Firefly CephFS file system to a newer Ceph version -than Jewel, you must first upgrade to Jewel and run the ``tmap_upgrade`` -command before completing your upgrade to the latest version. - From 77764dbe66e7ef86b69d8d280852f0fe68676458 Mon Sep 17 00:00:00 2001 From: Gil Bregman Date: Mon, 22 Jun 2026 12:06:00 +0300 Subject: [PATCH 366/596] mgr/cephadm: Add degraded namespace flag to NVMEoF spec file Fixes: https://tracker.ceph.com/issues/77556 Signed-off-by: Gil Bregman --- .../mgr/cephadm/templates/services/nvmeof/ceph-nvmeof.conf.j2 | 1 + src/pybind/mgr/cephadm/tests/services/test_nvmeof.py | 3 +++ src/python-common/ceph/deployment/service_spec.py | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/src/pybind/mgr/cephadm/templates/services/nvmeof/ceph-nvmeof.conf.j2 b/src/pybind/mgr/cephadm/templates/services/nvmeof/ceph-nvmeof.conf.j2 index e0743d95c64..78e32864b1b 100644 --- a/src/pybind/mgr/cephadm/templates/services/nvmeof/ceph-nvmeof.conf.j2 +++ b/src/pybind/mgr/cephadm/templates/services/nvmeof/ceph-nvmeof.conf.j2 @@ -53,6 +53,7 @@ force_tls = {{ spec.force_tls }} # This is a development flag, do not change it max_message_length_in_mb = {{ spec.max_message_length_in_mb }} io_stats_enabled = {{ spec.io_stats_enabled }} +degrade_namespace_on_kmip_error = {{ spec.degrade_namespace_on_kmip_error }} [gateway-logs] log_level = {{ spec.log_level }} diff --git a/src/pybind/mgr/cephadm/tests/services/test_nvmeof.py b/src/pybind/mgr/cephadm/tests/services/test_nvmeof.py index 88a1cc1458c..efbce7cee66 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_nvmeof.py +++ b/src/pybind/mgr/cephadm/tests/services/test_nvmeof.py @@ -150,6 +150,7 @@ force_tls = False # This is a development flag, do not change it max_message_length_in_mb = 4 io_stats_enabled = True +degrade_namespace_on_kmip_error = True [gateway-logs] log_level = INFO @@ -400,6 +401,7 @@ force_tls = False # This is a development flag, do not change it max_message_length_in_mb = 4 io_stats_enabled = True +degrade_namespace_on_kmip_error = True [gateway-logs] log_level = INFO @@ -592,6 +594,7 @@ force_tls = False # This is a development flag, do not change it max_message_length_in_mb = 4 io_stats_enabled = True +degrade_namespace_on_kmip_error = True [gateway-logs] log_level = INFO diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 8e183f2414b..bf2429b8b68 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -1897,6 +1897,7 @@ class NvmeofServiceSpec(ServiceSpec): force_tls: Optional[bool] = False, max_message_length_in_mb: Optional[int] = 4, io_stats_enabled: Optional[bool] = True, + degrade_namespace_on_kmip_error: Optional[bool] = True, server_key: Optional[str] = None, server_cert: Optional[str] = None, client_key: Optional[str] = None, @@ -2056,6 +2057,8 @@ class NvmeofServiceSpec(ServiceSpec): self.max_message_length_in_mb = max_message_length_in_mb #: ``io_stats_enabled`` enables controller IO statistics self.io_stats_enabled = io_stats_enabled + #: ``degrade_namespace_on_kmip_error`` on a KMIP key error in update, create a degraded ns + self.degrade_namespace_on_kmip_error = degrade_namespace_on_kmip_error #: ``allowed_consecutive_spdk_ping_failures`` # of ping failures before aborting gateway self.allowed_consecutive_spdk_ping_failures = allowed_consecutive_spdk_ping_failures #: ``spdk_ping_interval_in_seconds`` sleep interval in seconds between SPDK pings @@ -2305,6 +2308,7 @@ class NvmeofServiceSpec(ServiceSpec): verify_boolean(self.force_tls, "Force TLS") verify_positive_int(self.max_message_length_in_mb, "Max protocol message length") verify_boolean(self.io_stats_enabled, "Enable IO statistics") + verify_boolean(self.degrade_namespace_on_kmip_error, "Degrade namespace on KMIP error") verify_non_negative_number(self.monitor_timeout, "Monitor timeout") verify_non_negative_int(self.port, "Port") verify_non_negative_int(self.discovery_port, "Discovery port") From 57e0250f5738dcea3adf4193d82f00b90c52c136 Mon Sep 17 00:00:00 2001 From: Matthew Heler Date: Fri, 5 Jun 2026 10:48:32 -0500 Subject: [PATCH 367/596] rgw: fix AES-256-GCM key/IV reuse on multipart part re-upload Re-uploading the same part number in a GCM multipart upload encrypted the new data under the same key and IV as the first upload, since the IV is part_number||chunk_index and the part key came from the part number alone. GCM requires a unique IV per key; reusing one to encrypt different data weakens its confidentiality and integrity guarantees. Generate a random 16-byte salt on each UploadPart and fold it into the part key, HMAC(ObjectKey, BE32(part) || salt), so every upload gets a fresh key. The salt rides RGWUploadPartInfo, and complete stores the selected part's salt in RGW_ATTR_CRYPT_PART_NUMS, which now holds (part, salt) pairs. GET reads it back to re-derive the key, and an empty salt reproduces the old derivation so unsalted parts still decrypt. Signed-off-by: Matthew N. Heler --- src/rgw/driver/rados/rgw_putobj_processor.cc | 8 ++ src/rgw/driver/rados/rgw_rados.cc | 14 ++- src/rgw/driver/rados/rgw_sal_rados.cc | 19 ++-- src/rgw/driver/rados/rgw_sal_rados.h | 1 + src/rgw/rgw_basic_types.h | 9 +- src/rgw/rgw_common.h | 1 + src/rgw/rgw_crypt.cc | 99 +++++++++++--------- src/rgw/rgw_crypt.h | 11 ++- src/rgw/rgw_multi.cc | 1 + src/rgw/rgw_op.cc | 40 +++++--- src/rgw/rgw_rest_s3.cc | 25 ++++- src/rgw/rgw_sal.h | 3 + src/rgw/rgw_sal_filter.h | 2 + src/test/rgw/test_rgw_crypto.cc | 41 ++++++++ 14 files changed, 193 insertions(+), 81 deletions(-) diff --git a/src/rgw/driver/rados/rgw_putobj_processor.cc b/src/rgw/driver/rados/rgw_putobj_processor.cc index ee194eb7533..3292e1c4e97 100644 --- a/src/rgw/driver/rados/rgw_putobj_processor.cc +++ b/src/rgw/driver/rados/rgw_putobj_processor.cc @@ -563,6 +563,13 @@ int MultipartObjectProcessor::complete( obj_op.meta.if_match = if_match; obj_op.meta.if_nomatch = if_nomatch; + // Move the transient GCM salt onto the part info and drop it from attrs (never on the head). + std::string part_salt; + if (auto i = attrs.find(RGW_ATTR_CRYPT_PART_SALT); i != attrs.end()) { + part_salt = i->second.to_str(); + attrs.erase(i); + } + r = obj_op.write_meta(actual_size, accounted_size, attrs, rctx, writer.get_trace(), flags & rgw::sal::FLAG_LOG_OP); if (r < 0) @@ -586,6 +593,7 @@ int MultipartObjectProcessor::complete( info.accounted_size = accounted_size; info.modified = real_clock::now(); info.manifest = manifest; + info.crypt_salt = std::move(part_salt); bool compressed; r = rgw_compression_info_from_attrset(attrs, compressed, info.cs_info); diff --git a/src/rgw/driver/rados/rgw_rados.cc b/src/rgw/driver/rados/rgw_rados.cc index 4e78e18c2b7..5907e907534 100644 --- a/src/rgw/driver/rados/rgw_rados.cc +++ b/src/rgw/driver/rados/rgw_rados.cc @@ -7969,16 +7969,14 @@ int RGWRados::Object::Read::prepare(optional_yield y, const DoutPrefixProvider * } for (auto& iter : src_attrset) { - /** - * Skip object-level encryption attributes when reading individual parts. - * These attrs describe the complete multipart object, not this part: - * - ORIGINAL_SIZE: would cause Content-Length mismatch - * - PARTS: contains sizes of all parts, not applicable to single part - * - PART_NUMS: maps part indices to S3 part numbers for the full object + /* + * Skip object-level crypt attrs (ORIGINAL_SIZE, PARTS) that describe the + * whole object and would break a single-part read. PART_NUMS passes + * through; it carries the per-part (num, salt) needed to derive this + * part's key. */ if (iter.first == RGW_ATTR_CRYPT_ORIGINAL_SIZE || - iter.first == RGW_ATTR_CRYPT_PARTS || - iter.first == RGW_ATTR_CRYPT_PART_NUMS) { + iter.first == RGW_ATTR_CRYPT_PARTS) { ldpp_dout(dpp, 4) << "skip crypt attr for part read: " << iter.first << dendl; continue; } diff --git a/src/rgw/driver/rados/rgw_sal_rados.cc b/src/rgw/driver/rados/rgw_sal_rados.cc index 3825d25aced..a4a3987d6c2 100644 --- a/src/rgw/driver/rados/rgw_sal_rados.cc +++ b/src/rgw/driver/rados/rgw_sal_rados.cc @@ -4290,6 +4290,8 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp, std::string crypt_mode = mode_iter->second.to_str(); is_aead = is_aead_mode(crypt_mode); } + // AEAD: (S3 part number, GCM salt) per selected part, in manifest-segment order. + std::vector> part_keys; do { ret = list_parts(dpp, cct, max_parts, marker, &marker, &truncated, y); @@ -4409,6 +4411,7 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp, // Track plaintext size for AEAD encryption if (is_aead) { + part_keys.push_back({obj_part.num, obj_part.crypt_salt}); if (part_compressed) { // For compressed parts, use the uncompressed size directly plaintext_ofs += obj_part.accounted_size; @@ -4460,17 +4463,13 @@ int RadosMultipartUpload::complete(const DoutPrefixProvider *dpp, bl.append(std::to_string(plaintext_ofs)); attrs[RGW_ATTR_CRYPT_ORIGINAL_SIZE] = std::move(bl); - // Store actual S3 part numbers for correct IV/key derivation during decrypt - std::vector part_nums; - part_nums.reserve(part_etags.size()); - for (const auto& part : part_etags) { - part_nums.push_back(static_cast(part.first)); - } - bufferlist part_nums_bl; + // Store (S3 part number, GCM salt) pairs (collected in selection order + // above) for correct key derivation during decrypt. + bufferlist part_keys_bl; using ceph::encode; - encode(part_nums, part_nums_bl); - attrs[RGW_ATTR_CRYPT_PART_NUMS] = std::move(part_nums_bl); - ldpp_dout(dpp, 20) << "Stored CRYPT_PART_NUMS with " << part_nums.size() + encode(part_keys, part_keys_bl); + attrs[RGW_ATTR_CRYPT_PART_NUMS] = std::move(part_keys_bl); + ldpp_dout(dpp, 20) << "Stored CRYPT_PART_NUMS with " << part_keys.size() << " parts" << dendl; } diff --git a/src/rgw/driver/rados/rgw_sal_rados.h b/src/rgw/driver/rados/rgw_sal_rados.h index 22b0d1d7e5a..ff0eae39894 100644 --- a/src/rgw/driver/rados/rgw_sal_rados.h +++ b/src/rgw/driver/rados/rgw_sal_rados.h @@ -866,6 +866,7 @@ public: virtual const ACLOwner& get_owner() const override { return owner; } virtual ceph::real_time& get_mtime() override { return mtime; } virtual std::unique_ptr get_meta_obj() override; + virtual bool supports_crypt_part_salts() const override { return true; } virtual int init(const DoutPrefixProvider* dpp, optional_yield y, ACLOwner& owner, rgw_placement_rule& dest_placement, rgw::sal::Attrs& attrs) override; virtual int list_parts(const DoutPrefixProvider* dpp, CephContext* cct, int num_parts, int marker, diff --git a/src/rgw/rgw_basic_types.h b/src/rgw/rgw_basic_types.h index 9a50ff3aba0..99ea71db34d 100644 --- a/src/rgw/rgw_basic_types.h +++ b/src/rgw/rgw_basic_types.h @@ -278,6 +278,7 @@ struct RGWUploadPartInfo { RGWObjManifest manifest; RGWCompressionInfo cs_info; std::optional cksum; + std::string crypt_salt; // per-UploadPart GCM salt (non-secret HMAC input) // Previous part obj prefixes. Recorded here for later cleanup. std::set past_prefixes; @@ -285,7 +286,7 @@ struct RGWUploadPartInfo { RGWUploadPartInfo() : num(0), size(0) {} void encode(bufferlist& bl) const { - ENCODE_START(6, 2, bl); + ENCODE_START(7, 2, bl); encode(num, bl); encode(size, bl); encode(etag, bl); @@ -295,10 +296,11 @@ struct RGWUploadPartInfo { encode(accounted_size, bl); encode(past_prefixes, bl); encode(cksum, bl); + encode(crypt_salt, bl); ENCODE_FINISH(bl); } void decode(bufferlist::const_iterator& bl) { - DECODE_START_LEGACY_COMPAT_LEN(6, 2, 2, bl); + DECODE_START_LEGACY_COMPAT_LEN(7, 2, 2, bl); decode(num, bl); decode(size, bl); decode(etag, bl); @@ -317,6 +319,9 @@ struct RGWUploadPartInfo { if (struct_v >= 6) { decode(cksum, bl); } + if (struct_v >= 7) { + decode(crypt_salt, bl); + } DECODE_FINISH(bl); } void dump(Formatter *f) const; diff --git a/src/rgw/rgw_common.h b/src/rgw/rgw_common.h index 14843124c66..bd7851b1786 100644 --- a/src/rgw/rgw_common.h +++ b/src/rgw/rgw_common.h @@ -192,6 +192,7 @@ using ceph::crypto::MD5; #define RGW_ATTR_CRYPT_DATAKEY RGW_ATTR_CRYPT_PREFIX "datakey" #define RGW_ATTR_CRYPT_PARTS RGW_ATTR_CRYPT_PREFIX "part-lengths" #define RGW_ATTR_CRYPT_PART_NUMS RGW_ATTR_CRYPT_PREFIX "part-numbers" +#define RGW_ATTR_CRYPT_PART_SALT RGW_ATTR_CRYPT_PREFIX "part-salt" #define RGW_ATTR_CRYPT_SALT RGW_ATTR_CRYPT_PREFIX "salt" #define RGW_ATTR_CRYPT_ORIGINAL_SIZE RGW_ATTR_CRYPT_PREFIX "original-size" #define RGW_ATTR_CRYPT_PREFETCH_ALIGN RGW_ATTR_CRYPT_PREFIX "prefetch-align" diff --git a/src/rgw/rgw_crypt.cc b/src/rgw/rgw_crypt.cc index e572779af3a..37eb0f02a6b 100644 --- a/src/rgw/rgw_crypt.cc +++ b/src/rgw/rgw_crypt.cc @@ -674,6 +674,7 @@ private: uint8_t salt[AES_256_GCM_SALT_SIZE]; bool salt_initialized = false; uint32_t part_number_ = 0; // For multipart: ensures unique IVs across parts + bool part_salt_applied_ = false; std::once_flag gcm_accel_init_once; CryptoAccelRef gcm_accel; @@ -731,27 +732,22 @@ public: return salt_initialized; } - /** - * Set part number for multipart IV derivation and key derivation (SSE-C). - * Must be called before encrypt/decrypt for multipart uploads. - * - * For SSE-C mode (has_base_key=true): also re-derives the part-specific key - * from base_key, enabling correct decryption when switching between parts - * during multipart GET operations. + /* + * For a multipart part, re-derive the part key from base_key with the salt. + * has_base_key holds for all GCM modes. */ - void set_part_number(uint32_t part_number) override { + void set_part_number(uint32_t part_number, + std::string_view part_salt = {}) override { this->part_number_ = part_number; + this->part_salt_applied_ = !part_salt.empty(); - // For SSE-C mode, also derive the correct part key if (has_base_key && part_number > 0) { - // Restore base key, then derive part key memcpy(this->key, this->base_key, AES_256_KEYSIZE); - derive_part_key(part_number); + derive_part_key(part_number, part_salt); } else if (has_base_key && part_number == 0) { // Part 0 means single-part or init - use base key directly memcpy(this->key, this->base_key, AES_256_KEYSIZE); } - // For non-SSE-C modes (has_base_key=false), only IV derivation uses part_number } /** @@ -845,16 +841,12 @@ public: return true; } - /** - * Derive part-specific key for multipart uploads. - * This prevents part reordering/swapping attacks. - * - * Formula: PartKey = HMAC-SHA256(ObjectKey, part_number) - * - * @param part_number Part number (1-based, as per S3 multipart API) - * @return true on success + /* + * PartKey = HMAC-SHA256(ObjectKey, BE32(part_number) || part_salt). + * Binds the key to the part number, and with a non-empty salt to the upload + * so re-uploading a part can't reuse (key, IV). */ - bool derive_part_key(uint32_t part_number) { + bool derive_part_key(uint32_t part_number, std::string_view part_salt = {}) { // Encode part number as big-endian 4 bytes uint8_t part_bytes[4]; part_bytes[0] = (part_number >> 24) & 0xFF; @@ -867,6 +859,9 @@ public: try { ceph::crypto::HMACSHA256 hmac(this->key, AES_256_KEYSIZE); hmac.Update(part_bytes, 4); + if (!part_salt.empty()) { + hmac.Update(reinterpret_cast(part_salt.data()), part_salt.size()); + } hmac.Final(derived); } catch (const ceph::crypto::DigestException& e) { ldpp_dout(dpp, 0) << "ERROR: derive_part_key: HMAC failed: " << e.what() << dendl; @@ -1067,6 +1062,18 @@ public: { output.clear(); + // write-path nonce-uniqueness guards: fresh per-part salt + chunk-aligned offset + if (part_number_ > 0 && !part_salt_applied_) { + ldpp_dout(dpp, 0) << "GCM: multipart part " << part_number_ + << " missing per-part salt; refusing to encrypt" << dendl; + return false; + } + if (stream_offset % static_cast(CHUNK_SIZE) != 0) { + ldpp_dout(dpp, 0) << "GCM: stream_offset " << stream_offset + << " not chunk-aligned (" << CHUNK_SIZE << ")" << dendl; + return false; + } + // Calculate output size: each CHUNK_SIZE plaintext becomes CHUNK_SIZE + GCM_TAG_SIZE size_t num_full_chunks = size / CHUNK_SIZE; size_t remainder = size % CHUNK_SIZE; @@ -1397,7 +1404,7 @@ RGWGetObj_BlockDecrypt::RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp, RGWGetObj_Filter* next, std::unique_ptr crypt, std::vector parts_len, - std::vector part_nums, + std::vector> part_keys, off_t encrypted_total_size, bool has_compression, optional_yield y) @@ -1415,38 +1422,38 @@ RGWGetObj_BlockDecrypt::RGWGetObj_BlockDecrypt(const DoutPrefixProvider *dpp, cache(), y(y), parts_len(std::move(parts_len)), - part_nums(std::move(part_nums)), + part_keys(std::move(part_keys)), current_part_num(0) { block_size = this->crypt->get_block_size(); encrypted_block_size = this->crypt->get_encrypted_block_size(); /** - * Sanity check: when BOTH part_nums and parts_len are populated, they must + * Sanity check: when BOTH part_keys and parts_len are populated, they must * match in size. A mismatch indicates data corruption or a bug. * * When parts_len is empty (e.g., GET ?partNumber=N where CRYPT_PARTS is * intentionally skipped and the part object has no manifest), we can only - * trust a single fallback part number. + * trust a single fallback part. */ - if (!this->part_nums.empty() && !this->parts_len.empty() && - this->part_nums.size() != this->parts_len.size()) { - ldpp_dout(dpp, 0) << "ERROR: part_nums.size()=" << this->part_nums.size() + if (!this->part_keys.empty() && !this->parts_len.empty() && + this->part_keys.size() != this->parts_len.size()) { + ldpp_dout(dpp, 0) << "ERROR: part_keys.size()=" << this->part_keys.size() << " != parts_len.size()=" << this->parts_len.size() << " - possible data corruption" << dendl; - this->part_nums.clear(); + this->part_keys.clear(); } - if (this->parts_len.empty() && this->part_nums.size() > 1) { - ldpp_dout(dpp, 0) << "ERROR: part_nums.size()=" << this->part_nums.size() + if (this->parts_len.empty() && this->part_keys.size() > 1) { + ldpp_dout(dpp, 0) << "ERROR: part_keys.size()=" << this->part_keys.size() << " but parts_len is empty - cannot map part boundaries" << dendl; - this->part_nums.clear(); + this->part_keys.clear(); } // Initialize with first part's key if multipart - if (!this->part_nums.empty()) { - current_part_num = this->part_nums[0]; - this->crypt->set_part_number(current_part_num); + if (!this->part_keys.empty()) { + current_part_num = this->part_keys[0].first; + this->crypt->set_part_number(current_part_num, this->part_keys[0].second); } } @@ -1551,15 +1558,17 @@ int RGWGetObj_BlockDecrypt::process(bufferlist& in, size_t part_ofs, size_t size int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out) { size_t enc_part_ofs = enc_ofs; size_t plain_part_ofs = ofs; - const bool is_multipart = !part_nums.empty(); + const bool is_multipart = !part_keys.empty(); uint32_t part_idx = 0; int res = 0; for (size_t part : parts_len) { - // Get actual S3 part number from attribute (not calculated!) + // Get actual S3 part number + salt from the attribute (not calculated!) uint32_t this_part_num = 0; - if (is_multipart && part_idx < part_nums.size()) { - this_part_num = part_nums[part_idx]; + std::string_view this_salt; + if (is_multipart && part_idx < part_keys.size()) { + this_part_num = part_keys[part_idx].first; + this_salt = part_keys[part_idx].second; } if (enc_part_ofs >= part) { @@ -1571,7 +1580,7 @@ int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out) // Ensure cipher has correct part number if (is_multipart && current_part_num != this_part_num) { current_part_num = this_part_num; - crypt->set_part_number(current_part_num); + crypt->set_part_number(current_part_num, this_salt); } // Data crosses part boundary - process up to boundary @@ -1584,12 +1593,14 @@ int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out) // Move to next part part_idx++; uint32_t next_part_num = 0; - if (is_multipart && part_idx < part_nums.size()) { - next_part_num = part_nums[part_idx]; + std::string_view next_salt; + if (is_multipart && part_idx < part_keys.size()) { + next_part_num = part_keys[part_idx].first; + next_salt = part_keys[part_idx].second; } if (is_multipart && part_idx < parts_len.size() && current_part_num != next_part_num) { current_part_num = next_part_num; - crypt->set_part_number(current_part_num); + crypt->set_part_number(current_part_num, next_salt); } enc_part_ofs = 0; @@ -1598,7 +1609,7 @@ int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out) // Ensure cipher has correct part number if (is_multipart && current_part_num != this_part_num) { current_part_num = this_part_num; - crypt->set_part_number(current_part_num); + crypt->set_part_number(current_part_num, this_salt); } break; } diff --git a/src/rgw/rgw_crypt.h b/src/rgw/rgw_crypt.h index 386ad462ea0..7d54b0abf00 100644 --- a/src/rgw/rgw_crypt.h +++ b/src/rgw/rgw_crypt.h @@ -101,15 +101,18 @@ public: /** * Set the part number for multipart object decryption. - * AEAD modes use this for per-part IV derivation. + * AEAD modes use this for per-part IV derivation, and for GCM also fold the + * optional per-UploadPart salt into part-key derivation. * Default is no-op; CBC derives IVs from block offsets instead. */ - virtual void set_part_number(uint32_t part_number) {} + virtual void set_part_number(uint32_t part_number, + std::string_view part_salt = {}) {} }; static const size_t AES_256_KEYSIZE = 256 / 8; static const size_t AES_256_GCM_IV_SIZE = 96 / 8; // 12 bytes, GCM standard static const size_t AES_256_GCM_SALT_SIZE = 32; // 256-bit random salt for HMAC-based key derivation +static constexpr size_t AES_256_GCM_PART_SALT_SIZE = 16; // per-UploadPart entropy folded into part-key derivation /** * AEAD chunk size constants used for size calculations across RGW. @@ -266,7 +269,7 @@ class RGWGetObj_BlockDecrypt : public RGWGetObj_Filter { size_t encrypted_block_size; /**< snapshot of \ref BlockCrypt.get_encrypted_block_size() (includes auth tag for GCM) */ optional_yield y; std::vector parts_len; /**< size of parts of multipart object, parsed from manifest */ - std::vector part_nums; /**< actual S3 part numbers for multipart (e.g., [1,3,5]) */ + std::vector> part_keys; /**< per part: (S3 part number, GCM salt) */ uint32_t current_part_num = 0; /**< current part number (1-based, 0 means single-part object) */ int process(bufferlist& cipher, size_t part_ofs, size_t size); @@ -289,7 +292,7 @@ public: RGWGetObj_Filter* next, std::unique_ptr crypt, std::vector parts_len, - std::vector part_nums, + std::vector> part_keys, off_t encrypted_total_size, bool has_compression, optional_yield y); diff --git a/src/rgw/rgw_multi.cc b/src/rgw/rgw_multi.cc index 9abda447ea4..d7155dd6259 100644 --- a/src/rgw/rgw_multi.cc +++ b/src/rgw/rgw_multi.cc @@ -100,5 +100,6 @@ void RGWUploadPartInfo::dump(Formatter *f) const utime_t ut(modified); encode_json("modified", ut, f); encode_json("past_prefixes", past_prefixes, f); + encode_json("crypt_salt", crypt_salt, f); } diff --git a/src/rgw/rgw_op.cc b/src/rgw/rgw_op.cc index 4a00c1112fe..bfcb0ea6df1 100644 --- a/src/rgw/rgw_op.cc +++ b/src/rgw/rgw_op.cc @@ -10370,27 +10370,43 @@ int get_decrypt_filter( // correctly decrypt across part boundaries std::vector parts_len; - // Read actual S3 part numbers from attribute (set by CompleteMultipartUpload) - std::vector part_nums; + // Read (S3 part number, GCM salt) pairs from the attribute (set by Complete). + std::vector> part_keys; if (auto it = attrs.find(RGW_ATTR_CRYPT_PART_NUMS); it != attrs.end()) { try { auto p = it->second.cbegin(); using ceph::decode; - decode(part_nums, p); + decode(part_keys, p); } catch (const buffer::error&) { ldpp_dout(s, 1) << "failed to decode RGW_ATTR_CRYPT_PART_NUMS" << dendl; - // Continue with empty part_nums - will fail for multipart, ok for single-part + // Continue with empty part_keys - will fail for multipart, ok for single-part } } - /** - * Fallback for GET ?partNumber=N (single part read). - * When reading an individual part, the CRYPT_PART_NUMS attribute is skipped - * (see rgw_rados.cc skip list), so we use the requested part_num to ensure - * correct key derivation and IV generation. + /* + * GET ?partNumber=N receives the full pair vector; reduce it to the requested + * part so the salt/key derivation is correct and the vector matches the + * single-part manifest. */ - if (part_nums.empty() && part_num > 0) { - part_nums.push_back(part_num); + if (part_num > 0 && !part_keys.empty()) { + size_t idx = part_keys.size(); + for (size_t k = 0; k < part_keys.size(); k++) { + if (part_keys[k].first == static_cast(part_num)) { idx = k; break; } + } + if (idx == part_keys.size()) { + return -ERR_INVALID_PART; + } + auto sel = std::move(part_keys[idx]); + part_keys.clear(); + part_keys.push_back(std::move(sel)); + } + + /* + * No CRYPT_PART_NUMS attr but a part was requested: derive from part_num with + * an empty salt. + */ + if (part_keys.empty() && part_num > 0) { + part_keys.push_back({static_cast(part_num), {}}); } // for replicated objects, the original part lengths are preserved in an xattr @@ -10442,7 +10458,7 @@ int get_decrypt_filter( const bool has_compression = attrs.count(RGW_ATTR_COMPRESSION); *filter = std::make_unique( s, s->cct, cb, std::move(block_crypt), - std::move(parts_len), std::move(part_nums), encrypted_total_size, + std::move(parts_len), std::move(part_keys), encrypted_total_size, has_compression, s->yield); return 0; } diff --git a/src/rgw/rgw_rest_s3.cc b/src/rgw/rgw_rest_s3.cc index e0d1fc47e79..7dc6320c447 100644 --- a/src/rgw/rgw_rest_s3.cc +++ b/src/rgw/rgw_rest_s3.cc @@ -2958,6 +2958,10 @@ int RGWPutObj_ObjStore_S3::get_params(optional_yield y) ldpp_dout(s, 10) << "bad part number: " << multipart_part_str << ": " << err << dendl; return -EINVAL; } + if (multipart_part_num < 1 || multipart_part_num > 10000) { + ldpp_dout(s, 10) << "part number out of range: " << multipart_part_num << dendl; + return -EINVAL; + } } else if (!multipart_upload_id.empty()) { ldpp_dout(s, 10) << "part number with no multipart upload id" << dendl; return -EINVAL; @@ -3117,8 +3121,27 @@ int RGWPutObj_ObjStore_S3::get_encrypt_filter( res = rgw_s3_prepare_decrypt(s, s->yield, obj->get_attrs(), &block_crypt, &crypt_http_responses, copy_source, multipart_part_num); - if (res == 0 && block_crypt != nullptr) + if (res == 0 && block_crypt != nullptr) { + /* + * AEAD UploadPart: fold fresh per-UploadPart entropy into the part key so + * re-uploading the same part can't reuse (key, IV). Refuse the upload if + * the backend can't persist per-part salts rather than silently falling + * back to a deterministic part key. + */ + if (is_aead_mode(get_str_attribute(obj->get_attrs(), RGW_ATTR_CRYPT_MODE))) { + if (!upload->supports_crypt_part_salts()) { + ldpp_dout(this, 0) << "ERROR: AEAD multipart upload requires a supported backend" << dendl; + return -ERR_NOT_IMPLEMENTED; + } + std::string part_salt(AES_256_GCM_PART_SALT_SIZE, '\0'); + s->cct->random()->get_bytes(part_salt.data(), part_salt.size()); + block_crypt->set_part_number(multipart_part_num, part_salt); + // string_view selects the raw-bytes set_attr overload, not the local + // length-prefixing one; the writer reads it back via to_str(). + set_attr(this->attrs, RGW_ATTR_CRYPT_PART_SALT, std::string_view(part_salt)); + } filter->reset(new RGWPutObj_BlockEncrypt(s, s->cct, cb, std::move(block_crypt), s->yield)); + } } /* it is ok, to not have encryption at all */ } diff --git a/src/rgw/rgw_sal.h b/src/rgw/rgw_sal.h index 92e084ee34a..74d4b100a30 100644 --- a/src/rgw/rgw_sal.h +++ b/src/rgw/rgw_sal.h @@ -1543,6 +1543,9 @@ public: /** Get the Object that represents this upload */ virtual std::unique_ptr get_meta_obj() = 0; + /** True if this store persists per-part GCM salts; gates AEAD UploadPart salt emission. */ + virtual bool supports_crypt_part_salts() const { return false; } + /** Initialize this upload */ virtual int init(const DoutPrefixProvider* dpp, optional_yield y, ACLOwner& owner, rgw_placement_rule& dest_placement, rgw::sal::Attrs& attrs) = 0; /** List all the parts of this upload, filling the parts cache */ diff --git a/src/rgw/rgw_sal_filter.h b/src/rgw/rgw_sal_filter.h index c50b4cdeead..ecb55a98ee5 100644 --- a/src/rgw/rgw_sal_filter.h +++ b/src/rgw/rgw_sal_filter.h @@ -966,6 +966,8 @@ public: virtual std::unique_ptr get_meta_obj() override; + virtual bool supports_crypt_part_salts() const override { return next->supports_crypt_part_salts(); } + virtual int init(const DoutPrefixProvider* dpp, optional_yield y, ACLOwner& owner, rgw_placement_rule& dest_placement, rgw::sal::Attrs& attrs) override; virtual int list_parts(const DoutPrefixProvider* dpp, CephContext* cct, int num_parts, int marker, diff --git a/src/test/rgw/test_rgw_crypto.cc b/src/test/rgw/test_rgw_crypto.cc index f7cecb00b30..1b09963cc07 100644 --- a/src/test/rgw/test_rgw_crypto.cc +++ b/src/test/rgw/test_rgw_crypto.cc @@ -1202,16 +1202,19 @@ TEST(TestRGWCrypto, verify_AES_256_GCM_key_derivation) // Test 3: Different part numbers produce different derived keys { + const std::string part_salt(AES_256_GCM_PART_SALT_SIZE, 'p'); auto aes1(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32)); std::string salt = AES_256_GCM_get_salt(aes1.get()); ASSERT_TRUE(AES_256_GCM_derive_object_key(aes1.get(), user_key, 32, "bucket", "object", 1)); + aes1->set_part_number(1, part_salt); auto aes2(AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32, reinterpret_cast(salt.c_str()), salt.size())); ASSERT_TRUE(AES_256_GCM_derive_object_key(aes2.get(), user_key, 32, "bucket", "object", 2)); + aes2->set_part_number(2, part_salt); bufferlist input; input.append(plaintext); @@ -1245,6 +1248,44 @@ TEST(TestRGWCrypto, verify_AES_256_GCM_key_derivation) } } +TEST(TestRGWCrypto, verify_AES_256_GCM_part_salt_key_isolation) +{ + NoDoutPrefix no_dpp(g_ceph_context, ceph_subsys_rgw); + uint8_t user_key[32]; + for (size_t i = 0; i < sizeof(user_key); i++) user_key[i] = i; + + const std::string osalt(AES_256_GCM_SALT_SIZE, 'o'); + const std::string a(AES_256_GCM_PART_SALT_SIZE, 'a'); + const std::string b(AES_256_GCM_PART_SALT_SIZE, 'b'); + const uint8_t* os = reinterpret_cast(osalt.data()); + bufferlist in; + in.append("same part number, different salt"); + + auto mk = [&](const std::string& s) { + auto e = AES_256_GCM_create(&no_dpp, g_ceph_context, &user_key[0], 32, os, osalt.size()); + EXPECT_NE(e.get(), nullptr); + EXPECT_TRUE(AES_256_GCM_derive_object_key(e.get(), user_key, 32, "bucket", "object", 1)); + e->set_part_number(1, s); + return e; + }; + + // Same object key, same part number, different salt -> different ciphertext. + // This is false on the pre-fix binary (same part number -> same key+IV). + bufferlist ca, cb; + ASSERT_TRUE(mk(a)->encrypt(in, 0, in.length(), ca, 0, null_yield)); + ASSERT_TRUE(mk(b)->encrypt(in, 0, in.length(), cb, 0, null_yield)); + ASSERT_NE(std::string_view(ca.c_str(), ca.length()), + std::string_view(cb.c_str(), cb.length())); + + // Correct salt round-trips; wrong salt fails the GCM tag. + bufferlist pa; + ASSERT_TRUE(mk(a)->decrypt(ca, 0, ca.length(), pa, 0, null_yield)); + ASSERT_EQ(std::string_view(in.c_str(), in.length()), + std::string_view(pa.c_str(), pa.length())); + bufferlist bad; + ASSERT_FALSE(mk(b)->decrypt(ca, 0, ca.length(), bad, 0, null_yield)); +} + TEST(TestRGWCrypto, verify_AES_256_GCM_chunk_reorder_detection) { // Verify that swapping chunk positions is detected via AAD From 3a81a126089e201bcc0ddfcd800705d43f7d3b45 Mon Sep 17 00:00:00 2001 From: "Matthew N. Heler" Date: Mon, 8 Jun 2026 21:13:50 -0500 Subject: [PATCH 368/596] rgw: restore constant-time GCM tag comparison in ISA-L path a8ed43bfc05 replaced ct_memeq with memcmp in the ISA-L GCM accelerator, making tag verification and the key-cache compare non-constant-time. Restore ct_memeq for both; the OpenSSL and EVP paths already compare in constant time. Signed-off-by: Matthew N. Heler --- src/crypto/isa-l/isal_crypto_accel.cc | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/crypto/isa-l/isal_crypto_accel.cc b/src/crypto/isa-l/isal_crypto_accel.cc index 77049964812..3e815be16e0 100644 --- a/src/crypto/isa-l/isal_crypto_accel.cc +++ b/src/crypto/isa-l/isal_crypto_accel.cc @@ -48,6 +48,19 @@ bool ISALCryptoAccel::cbc_decrypt(unsigned char* out, const unsigned char* in, s return true; } +/* + * Constant-time byte comparison to prevent timing attacks on tag verification. + * Always compares all bytes regardless of differences found. + */ +static inline bool ct_memeq(const unsigned char* a, const unsigned char* b, size_t len) +{ + volatile unsigned char diff = 0; + for (size_t i = 0; i < len; ++i) { + diff |= static_cast(a[i] ^ b[i]); + } + return diff == 0; +} + /* * Thread-local GCM key cache to avoid re-running aes_gcm_pre_256() for * repeated keys. Key material is securely wiped on thread exit. @@ -69,7 +82,7 @@ static inline const gcm_key_data* get_cached_gcm_key(const unsigned char* key) if (!cache) cache = std::make_unique(); - if (memcmp(cache->last_key, key, CryptoAccel::AES_256_KEYSIZE) != 0) { + if (!ct_memeq(cache->last_key, key, CryptoAccel::AES_256_KEYSIZE)) { aes_gcm_pre_256(key, &cache->cached_gkey); memcpy(cache->last_key, key, CryptoAccel::AES_256_KEYSIZE); } @@ -127,7 +140,7 @@ bool ISALCryptoAccel::gcm_decrypt(unsigned char* out, const unsigned char* in, s static_cast(aad_len), computed_tag, AES_GCM_TAGSIZE); - if (memcmp(computed_tag, tag, AES_GCM_TAGSIZE) != 0) { + if (!ct_memeq(computed_tag, &tag[0], AES_GCM_TAGSIZE)) { memset(out, 0, size); // Clear output on auth failure return false; } From d0bf1d4987e74f8ee07405dcde2ceb1042ef47d5 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 20:14:10 +0800 Subject: [PATCH 369/596] Revert "script/run-make: enable ASan" in 8ac962c6, we enabled ASan in script/run-make, which is part of our CI workflow to build the tree. The goal was to enable us to identify memory related issues early, but this introduced two kinds of problems: - it's observed that some tests take around 4x time to complete in comparison to the test time without ASan enabled - api and dashboard e2e tests are failing because the ASan supppression rules are not populated to them This reverts commit 8ac962c698df959da1866d41b04d69c806bb72c0. Signed-off-by: Kefu Chai --- src/script/run-make.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/src/script/run-make.sh b/src/script/run-make.sh index e990a827de5..30ca35ce274 100755 --- a/src/script/run-make.sh +++ b/src/script/run-make.sh @@ -143,7 +143,6 @@ EOM local cxx_compiler="${discovered_cxx_compiler}" local c_compiler="${discovered_c_compiler}" local cmake_opts - cmake_opts+=" -DWITH_ASAN=ON" cmake_opts+=" -DCMAKE_CXX_COMPILER=$cxx_compiler -DCMAKE_C_COMPILER=$c_compiler" cmake_opts+=" -DENABLE_GIT_VERSION=OFF" cmake_opts+=" -DWITH_GTEST_PARALLEL=ON" From 9f59332b35259773783bbd38e4f92fa1520d6cbf Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 18 May 2026 02:31:58 +0530 Subject: [PATCH 370/596] tools/cephfs_mirror: Adds capability to persist metrics Adds the capability to persist mirroring metrics. The metrics are persisted in the omap of the cephfs-mirror object. Metrics are persisted asynchronously. Each mirrored directory path stores the corresponding metrics as the value of a unique omap key representing the mirrored directory. The omap key is as below. sync_stat/// Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/FSMirror.cc | 3 +- src/tools/cephfs_mirror/PeerReplayer.cc | 212 +++++++++++++++++++++++- src/tools/cephfs_mirror/PeerReplayer.h | 11 +- src/tools/cephfs_mirror/aio_utils.h | 10 ++ 4 files changed, 229 insertions(+), 7 deletions(-) diff --git a/src/tools/cephfs_mirror/FSMirror.cc b/src/tools/cephfs_mirror/FSMirror.cc index 4158361e7b9..ac19f158694 100644 --- a/src/tools/cephfs_mirror/FSMirror.cc +++ b/src/tools/cephfs_mirror/FSMirror.cc @@ -421,7 +421,8 @@ void FSMirror::add_peer(const Peer &peer) { } auto replayer = std::make_unique( - m_cct, this, m_cluster, m_filesystem, peer, m_directories, m_mount, m_service_daemon); + m_cct, this, m_cluster, m_filesystem, peer, m_directories, + std::make_shared(m_ioctx), m_mount, m_service_daemon); int r = init_replayer(replayer.get()); if (r < 0) { m_service_daemon->add_or_update_peer_attribute(m_filesystem.fscid, peer, diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 6b370df3c5c..6645374969c 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -10,6 +10,7 @@ #include #include "common/admin_socket.h" +#include "common/Clock.h" #include "common/ceph_context.h" #include "common/debug.h" #include "common/errno.h" @@ -20,6 +21,7 @@ #include "FSMirror.h" #include "PeerReplayer.h" #include "Utils.h" +#include "aio_utils.h" #include "json_spirit/json_spirit.h" @@ -117,6 +119,23 @@ std::string peer_config_key(const std::string &fs_name, const std::string &uuid) return PEER_CONFIG_KEY_PREFIX + "/" + fs_name + "/" + uuid; } +double monotime_to_double(monotime t) { + return sec_duration(t.time_since_epoch()).count(); +} + +struct C_PersistSyncStatAio : Context { + std::string dir_root; + + explicit C_PersistSyncStatAio(std::string dir_root_) + : dir_root(std::move(dir_root_)) {} + + void finish(int r) override { + if (r < 0) { + generic_derr << "cephfs::mirror: aio persist sync stats failed for dir_root=" + << dir_root << ": " << cpp_strerror(r) << dendl; + } + } +}; class PeerAdminSocketCommand { public: virtual ~PeerAdminSocketCommand() { @@ -201,10 +220,11 @@ private: PeerReplayer::PeerReplayer(CephContext *cct, FSMirror *fs_mirror, RadosRef local_cluster, const Filesystem &filesystem, const Peer &peer, const std::set> &directories, - MountRef mount, ServiceDaemon *service_daemon) + IoCtxRef local_ioctx, MountRef mount, ServiceDaemon *service_daemon) : m_cct(cct), m_fs_mirror(fs_mirror), m_local_cluster(local_cluster), + m_local_ioctx(local_ioctx), m_filesystem(filesystem), m_peer(peer), m_directories(directories.begin(), directories.end()), @@ -273,10 +293,6 @@ uint64_t percent_basis_points(uint64_t num, uint64_t den) { return static_cast((static_cast(num) * 10000.0) / den); } -double monotime_to_double(monotime t) { - return sec_duration(t.time_since_epoch()).count(); -} - void PeerReplayer::create_directory_perf_counters(const std::string &dir_root) { ceph_assert(m_directory_perf_counters.find(dir_root) == m_directory_perf_counters.end()); @@ -727,6 +743,192 @@ void PeerReplayer::remove_directory(string_view dir_root) { m_cond.notify_all(); } +std::string PeerReplayer::peer_sync_stat_omap_key(std::string_view dir_root) const { + // dir_root is usually absolute (e.g. "/d0"); avoid ".../uuid//d0" from an extra slash. + std::string d(dir_root); + while (!d.empty() && d.front() == '/') { + d.erase(0, 1); + } + return PEER_SYNC_STAT_KEY_PREFIX + "/" + m_filesystem.fs_name + "/" + m_peer.uuid + + "/" + d; +} + +void PeerReplayer::add_live_sync_metrics_to_persist(json_spirit::mObject &obj, + SnapSyncStat &sync_stat) { + if (sync_stat.current_syncing_snap) { + json_spirit::mObject snap; + snap["id"] = + json_spirit::mValue(static_cast(sync_stat.current_syncing_snap->first)); + snap["name"] = json_spirit::mValue(sync_stat.current_syncing_snap->second); + snap["sync-mode"] = json_spirit::mValue(sync_stat.snapdiff ? "delta" : "full"); + + double read_bps = sync_stat.read_time_sec > 0 ? + sync_stat.bytes_read / sync_stat.read_time_sec : 0; + double write_bps = sync_stat.write_time_sec > 0 ? + sync_stat.bytes_written / sync_stat.write_time_sec : 0; + snap["avg_read_throughput_bytes"] = + json_spirit::mValue(format_bytes(read_bps) + "/s"); + snap["avg_write_throughput_bytes"] = + json_spirit::mValue(format_bytes(write_bps) + "/s"); + + json_spirit::mObject crawl; + if (sync_stat.crawl_finished) { + crawl["state"] = json_spirit::mValue("completed"); + crawl["duration"] = json_spirit::mValue(format_time(sync_stat.crawl_duration)); + } else { + crawl["state"] = json_spirit::mValue("in-progress"); + auto cur_time = clock::now(); + sec_duration crawl_duration_till_now = + sec_duration(cur_time - sync_stat.crawl_start_time); + crawl["duration"] = + json_spirit::mValue(format_time(crawl_duration_till_now.count())); + } + snap["crawl"] = json_spirit::mValue(crawl); + + if (sync_stat.datasync_queue_wait_duration || + sync_stat.datasync_queue_wait_start_time) { + json_spirit::mObject datasync_queue_wait; + if (sync_stat.datasync_queue_wait_duration) { + datasync_queue_wait["state"] = json_spirit::mValue("completed"); + datasync_queue_wait["duration"] = + json_spirit::mValue(format_time(*sync_stat.datasync_queue_wait_duration)); + } else { + datasync_queue_wait["state"] = json_spirit::mValue("waiting"); + auto cur_time = clock::now(); + sec_duration dq_wait = + sec_duration(cur_time - *sync_stat.datasync_queue_wait_start_time); + datasync_queue_wait["duration"] = + json_spirit::mValue(format_time(dq_wait.count())); + } + snap["datasync_queue_wait"] = json_spirit::mValue(datasync_queue_wait); + } + + json_spirit::mObject bytes; + bytes["sync_bytes"] = json_spirit::mValue(format_bytes(sync_stat.sync_bytes)); + bytes["total_bytes"] = json_spirit::mValue(format_bytes(sync_stat.total_bytes)); + if (sync_stat.total_bytes > 0) { + double sync_pct = + (static_cast(sync_stat.sync_bytes) * 100.0) / sync_stat.total_bytes; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_pct << "%"; + bytes["sync_percent"] = json_spirit::mValue(os.str()); + } + snap["bytes"] = json_spirit::mValue(bytes); + + json_spirit::mObject files; + files["sync_files"] = + json_spirit::mValue(static_cast(sync_stat.sync_files)); + files["total_files"] = + json_spirit::mValue(static_cast(sync_stat.total_files)); + if (sync_stat.total_files > 0) { + double sync_file_pct = + (static_cast(sync_stat.sync_files) * 100.0) / sync_stat.total_files; + std::ostringstream os; + os << std::fixed << std::setprecision(2) << sync_file_pct << "%"; + files["sync_percent"] = json_spirit::mValue(os.str()); + } + snap["files"] = json_spirit::mValue(files); + + double eta = compute_eta(sync_stat); + if (eta == -1.0) { + snap["eta"] = json_spirit::mValue("calculating..."); + } else { + snap["eta"] = json_spirit::mValue(format_time(eta)); + } + + obj["state"] = json_spirit::mValue("syncing"); + obj["current_syncing_snap"] = json_spirit::mValue(snap); + } else if (sync_stat.failed) { + obj["state"] = json_spirit::mValue("failed"); + if (sync_stat.last_failed_reason) { + obj["failure_reason"] = json_spirit::mValue(*sync_stat.last_failed_reason); + } + } else { + obj["state"] = json_spirit::mValue("idle"); + } +} + +void PeerReplayer::add_last_sync_metrics_to_persist(json_spirit::mObject &obj, + SnapSyncStat &sync_stat) { + if (sync_stat.last_synced_snap) { + json_spirit::mObject snap; + snap["id"] = + json_spirit::mValue(static_cast(sync_stat.last_synced_snap->first)); + snap["name"] = json_spirit::mValue(sync_stat.last_synced_snap->second); + if (sync_stat.last_sync_crawl_duration) { + snap["crawl_duration"] = json_spirit::mValue(*sync_stat.last_sync_crawl_duration); + } + if (sync_stat.last_sync_datasync_queue_wait_duration) { + snap["datasync_queue_wait_duration"] = + json_spirit::mValue(*sync_stat.last_sync_datasync_queue_wait_duration); + } + if (sync_stat.last_sync_duration) { + snap["sync_duration"] = json_spirit::mValue(*sync_stat.last_sync_duration); + } + if (!clock::is_zero(sync_stat.last_synced)) { + snap["sync_time_stamp"] = + json_spirit::mValue(monotime_to_double(sync_stat.last_synced)); + } + if (sync_stat.last_sync_bytes) { + snap["sync_bytes"] = + json_spirit::mValue(static_cast(*sync_stat.last_sync_bytes)); + } + if (sync_stat.last_sync_files) { + snap["sync_files"] = + json_spirit::mValue(static_cast(*sync_stat.last_sync_files)); + } + obj["last_synced_snap"] = json_spirit::mValue(snap); + } + + obj["snaps_synced"] = + json_spirit::mValue(static_cast(sync_stat.synced_snap_count)); + obj["snaps_deleted"] = + json_spirit::mValue(static_cast(sync_stat.deleted_snap_count)); + obj["snaps_renamed"] = + json_spirit::mValue(static_cast(sync_stat.renamed_snap_count)); +} + +void PeerReplayer::persist_dir_sync_stat(const std::string &dir_root) { + ceph_assert(m_local_ioctx); + + SnapSyncStat sync_stat; + { + std::scoped_lock locker(m_lock); + auto it = m_snap_sync_stats.find(dir_root); + if (it == m_snap_sync_stats.end()) { + return; + } + sync_stat = it->second; + } + + json_spirit::mObject obj; + add_live_sync_metrics_to_persist(obj, sync_stat); + add_last_sync_metrics_to_persist(obj, sync_stat); + obj["metrics_updated_at"] = + json_spirit::mValue(static_cast(ceph_clock_now())); + obj["_instance_id"] = + json_spirit::mValue(stringify(m_local_ioctx->get_instance_id())); + + bufferlist bl; + bl.append(json_spirit::write(json_spirit::mValue(obj))); + std::map kv{{peer_sync_stat_omap_key(dir_root), bl}}; + + librados::ObjectWriteOperation write_op; + write_op.omap_set(kv); + + Context *ctx = new C_PersistSyncStatAio(dir_root); + librados::AioCompletion *aio_comp = create_rados_callback(ctx); + int r = m_local_ioctx->aio_operate(CEPHFS_MIRROR_OBJECT, aio_comp, &write_op); + if (r < 0) { + delete ctx; + derr << ": failed to submit aio persist sync stats for dir_root=" << dir_root + << ": " << cpp_strerror(r) << dendl; + aio_comp->release(); + return; + } + aio_comp->release(); +} + void PeerReplayer::enqueue_syncm(const std::shared_ptr& item) { dout(20) << ": Enqueue syncm object=" << item << dendl; std::lock_guard lock(smq_lock); diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 8d8d9f42119..08c214b2925 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -9,6 +9,7 @@ #include "mds/FSMap.h" #include "ServiceDaemon.h" #include "Types.h" +#include "json_spirit/json_spirit.h" #include #include @@ -24,7 +25,7 @@ public: PeerReplayer(CephContext *cct, FSMirror *fs_mirror, RadosRef local_cluster, const Filesystem &filesystem, const Peer &peer, const std::set> &directories, - MountRef mount, ServiceDaemon *service_daemon); + IoCtxRef local_ioctx, MountRef mount, ServiceDaemon *service_daemon); ~PeerReplayer(); // initialize replayer for a peer @@ -50,6 +51,7 @@ private: inline static const std::string SERVICE_DAEMON_FAILED_DIR_COUNT_KEY = "failure_count"; inline static const std::string SERVICE_DAEMON_RECOVERED_DIR_COUNT_KEY = "recovery_count"; + inline static const std::string PEER_SYNC_STAT_KEY_PREFIX = "sync_stat"; using Snapshot = std::pair; @@ -647,6 +649,7 @@ private: CephContext *m_cct; FSMirror *m_fs_mirror; RadosRef m_local_cluster; + IoCtxRef m_local_ioctx; Filesystem m_filesystem; Peer m_peer; // probably need to be encapsulated when supporting cancelations @@ -712,6 +715,12 @@ private: DirRegistry *registry); void unlock_directory(const std::string &dir_root, const DirRegistry ®istry); int sync_snaps(const std::string &dir_root, std::unique_lock &locker); + void persist_dir_sync_stat(const std::string &dir_root); + void add_live_sync_metrics_to_persist(json_spirit::mObject &obj, + SnapSyncStat &sync_stat); + void add_last_sync_metrics_to_persist(json_spirit::mObject &obj, + SnapSyncStat &sync_stat); + std::string peer_sync_stat_omap_key(std::string_view dir_root) const; int build_snap_map(const std::string &dir_root, std::map *snap_map, diff --git a/src/tools/cephfs_mirror/aio_utils.h b/src/tools/cephfs_mirror/aio_utils.h index 1aa40ef912c..c95a83a6491 100644 --- a/src/tools/cephfs_mirror/aio_utils.h +++ b/src/tools/cephfs_mirror/aio_utils.h @@ -4,11 +4,21 @@ #ifndef CEPHFS_MIRROR_AIO_UTILS_H #define CEPHFS_MIRROR_AIO_UTILS_H +#include "include/Context.h" #include "include/rados/librados.hpp" namespace cephfs { namespace mirror { +inline void rados_ctx_callback(rados_completion_t c, void *arg) { + Context *on_finish = reinterpret_cast(arg); + on_finish->complete(rados_aio_get_return_value(c)); +} + +inline librados::AioCompletion *create_rados_callback(Context *on_finish) { + return librados::Rados::aio_create_completion(on_finish, rados_ctx_callback); +} + template void rados_callback(rados_completion_t c, void *arg) { T *obj = reinterpret_cast(arg); From c5dd50967036529c819ecdad7bb86705a15f61c6 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 20 Jun 2026 11:03:49 +0530 Subject: [PATCH 371/596] tools/cephfs_mirror: Persist metrics to omap Persist snapshot mirroring metrics to the cephfs_mirror object omap for the mgr/mirroring module to support status command. The tick thread only keeps omap up to date for in-progress syncs on registered directories. Persist explicitly when stats change so omap is updated before a directory unregisters: - snap delete/rename propagation (inc_deleted_snap / inc_renamed_snap) - after each successful snap sync (following set_last_synced_stat) - after sync_snaps failure (following _inc_failed_count) - after sync_perms failure (following _inc_failed_count) Without the explicit call sites, omap can keep stale live or idle state after sync completes or fails because directories leave m_registered before the next tick. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 6645374969c..06206e5c8cc 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -1166,6 +1166,7 @@ int PeerReplayer::propagate_snap_deletes(const std::string &dir_root, return r; } inc_deleted_snap(dir_root); + persist_dir_sync_stat(dir_root); if (m_perf_counters) { m_perf_counters->inc(l_cephfs_mirror_peer_replayer_snaps_deleted); } @@ -1192,6 +1193,7 @@ int PeerReplayer::propagate_snap_renames( return r; } inc_renamed_snap(dir_root); + persist_dir_sync_stat(dir_root); if (m_perf_counters) { m_perf_counters->inc(l_cephfs_mirror_peer_replayer_snaps_renamed); } @@ -2849,6 +2851,7 @@ int PeerReplayer::do_sync_snaps(const std::string &dir_root) { } set_last_synced_stat(dir_root, it->first, it->second, duration); + persist_dir_sync_stat(dir_root); if (--snaps_per_cycle == 0) { break; } @@ -2878,6 +2881,11 @@ int PeerReplayer::sync_snaps(const std::string &dir_root, update_directory_current_sync_perf_counters(dir_perf, m_snap_sync_stats.at(dir_root)); } + if (r < 0) { + locker.unlock(); + persist_dir_sync_stat(dir_root); + locker.lock(); + } return r; } @@ -2898,6 +2906,18 @@ void PeerReplayer::run_tick() { for (const auto &kv : m_registered) { refresh_directory_current_sync_perf_counters(kv.first); } + + // persist sync stats to omap for registered directories + std::vector dirs; + dirs.reserve(m_registered.size()); + for (const auto &kv : m_registered) { + dirs.push_back(kv.first); + } + locker.unlock(); + for (const auto &dir_root : dirs) { + persist_dir_sync_stat(dir_root); + } + locker.lock(); } } @@ -2947,6 +2967,9 @@ void PeerReplayer::run(SnapshotReplayerThread *replayer) { update_directory_current_sync_perf_counters( dir_perf, m_snap_sync_stats.at(*dir_root)); } + locker.unlock(); + persist_dir_sync_stat(*dir_root); + locker.lock(); if (m_perf_counters) { m_perf_counters->inc(l_cephfs_mirror_peer_replayer_snap_sync_failures); } From 4b907eaf08869ef472b391774c421a68361a1f0a Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 20 Jun 2026 11:51:42 +0530 Subject: [PATCH 372/596] tools/cephfs_mirror: Load persisted mirror metrics from omap Load last_synced_snap metadata from the cephfs_mirror object omap on PeerReplayer initialization and when a mirrored directory is added. Live current_syncing_snap metrics are not restored; they are rebuilt when synchronization starts. When the daemon restarts after a snapshot was synced on the remote but metrics were not yet written to omap, loaded metadata may belong to an older snapshot. Add reconcile_last_synced_snap() to compare against the remote snap map, clear stale last-sync fields, and update last_synced_snap id/name in memory. Treat snaps_synced, snaps_deleted, and snaps_renamed as per-session counters. Do not load them from omap; they start at zero for each daemon session and are still reported via the admin socket. Persist omap metrics unconditionally after reconcile so the mgr picks up the new instance id and cleared session counters on restart. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/tools/cephfs_mirror/PeerReplayer.cc | 282 ++++++++++++++++++++++-- src/tools/cephfs_mirror/PeerReplayer.h | 37 +++- 2 files changed, 294 insertions(+), 25 deletions(-) diff --git a/src/tools/cephfs_mirror/PeerReplayer.cc b/src/tools/cephfs_mirror/PeerReplayer.cc index 06206e5c8cc..aafc6eba298 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.cc +++ b/src/tools/cephfs_mirror/PeerReplayer.cc @@ -89,6 +89,9 @@ namespace { const std::string PEER_CONFIG_KEY_PREFIX = "cephfs/mirror/peer"; +// Matches the mgr mirroring omap read batch size (dir_map/load.py MAX_RETURN). +constexpr size_t SYNC_STAT_OMAP_LOAD_BATCH_SIZE = 256; + std::string snapshot_dir_path(CephContext *cct, const std::string &path) { return path + "/" + cct->_conf->client_snapdir; } @@ -119,10 +122,26 @@ std::string peer_config_key(const std::string &fs_name, const std::string &uuid) return PEER_CONFIG_KEY_PREFIX + "/" + fs_name + "/" + uuid; } +bool get_json_value(const json_spirit::mObject& obj, + const std::string& key, + json_spirit::mValue *val) { + auto it = obj.find(key); + if (it != obj.end()) { + *val = it->second; + return true; + } + return false; +} + double monotime_to_double(monotime t) { return sec_duration(t.time_since_epoch()).count(); } +monotime monotime_from_double(double seconds) { + auto d = std::chrono::duration_cast(sec_duration(seconds)); + return monotime(d); +} + struct C_PersistSyncStatAio : Context { std::string dir_root; @@ -136,6 +155,21 @@ struct C_PersistSyncStatAio : Context { } } }; + +struct C_RemovePersistedSyncStatAio : Context { + std::string dir_root; + + explicit C_RemovePersistedSyncStatAio(std::string dir_root_) + : dir_root(std::move(dir_root_)) {} + + void finish(int r) override { + if (r < 0 && r != -ENOENT) { + generic_derr << "cephfs::mirror: aio remove persisted sync stats failed for dir_root=" + << dir_root << ": " << cpp_strerror(r) << dendl; + } + } +}; + class PeerAdminSocketCommand { public: virtual ~PeerAdminSocketCommand() { @@ -559,6 +593,7 @@ int PeerReplayer::init() { for (auto &dir_root : m_directories) { m_snap_sync_stats.emplace(dir_root, SnapSyncStat()); } + load_persisted_dir_sync_stats(); for (auto &dir_root : m_directories) { create_directory_perf_counters(dir_root); auto *dir_perf = find_directory_perf_counters(dir_root); @@ -703,14 +738,18 @@ void PeerReplayer::add_directory(string_view dir_root) { dout(20) << ": dir_root=" << dir_root << dendl; auto _dir_root = std::string(dir_root); - std::scoped_lock locker(m_lock); - if (std::find(m_directories.begin(), m_directories.end(), _dir_root) != - m_directories.end()) { - dout(10) << ": dir_root=" << _dir_root << " already in replay list" << dendl; - return; + { + std::scoped_lock locker(m_lock); + if (std::find(m_directories.begin(), m_directories.end(), _dir_root) != + m_directories.end()) { + dout(10) << ": dir_root=" << _dir_root << " already in replay list" << dendl; + return; + } + m_directories.emplace_back(_dir_root); + m_snap_sync_stats.emplace(_dir_root, SnapSyncStat()); } - m_directories.emplace_back(_dir_root); - m_snap_sync_stats.emplace(_dir_root, SnapSyncStat()); + load_persisted_dir_sync_stat(_dir_root); + std::scoped_lock locker(m_lock); if (m_directory_perf_counters.find(_dir_root) == m_directory_perf_counters.end()) { create_directory_perf_counters(_dir_root); @@ -723,24 +762,44 @@ void PeerReplayer::add_directory(string_view dir_root) { m_cond.notify_all(); } -void PeerReplayer::remove_directory(string_view dir_root) { - dout(20) << ": dir_root=" << dir_root << dendl; +void PeerReplayer::remove_directory(string_view dir_root, bool purging) { + dout(20) << ": dir_root=" << dir_root << ", purging=" << purging << dendl; auto _dir_root = std::string(dir_root); + bool persist_idle = false; - std::scoped_lock locker(m_lock); - auto it = std::find(m_directories.begin(), m_directories.end(), _dir_root); - if (it != m_directories.end()) { - m_directories.erase(it); + { + std::scoped_lock locker(m_lock); + auto it = std::find(m_directories.begin(), m_directories.end(), _dir_root); + if (it != m_directories.end()) { + m_directories.erase(it); + } + + auto it1 = m_registered.find(_dir_root); + if (it1 == m_registered.end()) { + if (purging) { + remove_persisted_dir_sync_stat(_dir_root); + } + remove_directory_perf_counters(_dir_root); + m_snap_sync_stats.erase(_dir_root); + } else { + it1->second.canceled = true; + if (purging) { + m_purging_directories.insert(_dir_root); + } else { + auto &sync_stat = m_snap_sync_stats.at(_dir_root); + sync_stat.current_syncing_snap = boost::none; + if (auto *dir_perf = find_directory_perf_counters(_dir_root)) { + update_directory_current_sync_perf_counters(dir_perf, sync_stat); + } + persist_idle = true; + } + } + m_cond.notify_all(); } - auto it1 = m_registered.find(_dir_root); - if (it1 == m_registered.end()) { - remove_directory_perf_counters(_dir_root); - m_snap_sync_stats.erase(_dir_root); - } else { - it1->second.canceled = true; + if (persist_idle) { + persist_dir_sync_stat(_dir_root); } - m_cond.notify_all(); } std::string PeerReplayer::peer_sync_stat_omap_key(std::string_view dir_root) const { @@ -753,6 +812,167 @@ std::string PeerReplayer::peer_sync_stat_omap_key(std::string_view dir_root) con + "/" + d; } +void PeerReplayer::apply_persisted_dir_sync_stat(SnapSyncStat &sync_stat, + const bufferlist &bl) { + json_spirit::mValue root; + if (!json_spirit::read(bl.to_str(), root) || root.type() != json_spirit::obj_type) { + return; + } + + auto &obj = root.get_obj(); + json_spirit::mValue v; + + if (get_json_value(obj, "last_synced_snap", &v) && v.type() == json_spirit::obj_type) { + auto &last_synced_snap = v.get_obj(); + if (get_json_value(last_synced_snap, "id", &v)) { + uint64_t snap_id = v.get_uint64(); + if (get_json_value(last_synced_snap, "name", &v)) { + sync_stat.last_synced_snap = std::make_pair(snap_id, v.get_str()); + } + } + if (get_json_value(last_synced_snap, "crawl_duration", &v)) { + sync_stat.last_sync_crawl_duration = v.get_real(); + } + if (get_json_value(last_synced_snap, "datasync_queue_wait_duration", &v)) { + sync_stat.last_sync_datasync_queue_wait_duration = v.get_real(); + } + if (get_json_value(last_synced_snap, "sync_duration", &v)) { + sync_stat.last_sync_duration = v.get_real(); + } + if (get_json_value(last_synced_snap, "sync_time_stamp", &v)) { + sync_stat.last_synced = monotime_from_double(v.get_real()); + } + if (get_json_value(last_synced_snap, "sync_bytes", &v)) { + sync_stat.last_sync_bytes = v.get_uint64(); + } + if (get_json_value(last_synced_snap, "sync_files", &v)) { + sync_stat.last_sync_files = v.get_uint64(); + } + } +} + +void PeerReplayer::load_persisted_dir_sync_stat(const std::string &dir_root) { + ceph_assert(m_local_ioctx); + + const std::string key = peer_sync_stat_omap_key(dir_root); + std::set keys{key}; + std::map vals; + // Sync call is fine since it's called once during add_directory + int r = m_local_ioctx->omap_get_vals_by_keys(CEPHFS_MIRROR_OBJECT, keys, &vals); + if (r == -ENOENT) { + return; + } + if (r < 0) { + derr << ": failed to read persisted sync stat for dir_root=" << dir_root + << ": " << cpp_strerror(r) << dendl; + return; + } + + auto vit = vals.find(key); + if (vit == vals.end()) { + return; + } + + std::scoped_lock locker(m_lock); + auto st_it = m_snap_sync_stats.find(dir_root); + if (st_it == m_snap_sync_stats.end()) { + return; + } + apply_persisted_dir_sync_stat(st_it->second, vit->second); +} + +void PeerReplayer::load_persisted_dir_sync_stats() { + ceph_assert(m_local_ioctx); + if (m_directories.empty()) { + return; + } + + std::vector omap_keys; + omap_keys.reserve(m_directories.size()); + std::map key_to_dir; + for (const auto &dir_root : m_directories) { + const std::string key = peer_sync_stat_omap_key(dir_root); + omap_keys.push_back(key); + key_to_dir[key] = dir_root; + } + + std::map all_vals; + + auto fetch_keys = [&](const std::set &keys) -> int { + if (keys.empty()) { + return 0; + } + std::map vals; + // Sync call is fine since the stats are loaded once during startup + int r = m_local_ioctx->omap_get_vals_by_keys(CEPHFS_MIRROR_OBJECT, keys, &vals); + if (r == -ENOENT) { + return 0; + } + if (r < 0) { + return r; + } + all_vals.merge(std::move(vals)); + return 0; + }; + + if (m_directories.size() <= SYNC_STAT_OMAP_LOAD_BATCH_SIZE) { + std::set keys(omap_keys.begin(), omap_keys.end()); + int r = fetch_keys(keys); + if (r < 0) { + derr << ": failed to read persisted sync stats: " << cpp_strerror(r) << dendl; + return; + } + } else { + for (size_t i = 0; i < omap_keys.size(); i += SYNC_STAT_OMAP_LOAD_BATCH_SIZE) { + std::set keys; + const size_t end = std::min(i + SYNC_STAT_OMAP_LOAD_BATCH_SIZE, omap_keys.size()); + for (size_t j = i; j < end; ++j) { + keys.insert(omap_keys[j]); + } + int r = fetch_keys(keys); + if (r < 0) { + derr << ": failed to read persisted sync stats: " << cpp_strerror(r) << dendl; + return; + } + } + } + + std::scoped_lock locker(m_lock); + for (auto &[key, bl] : all_vals) { + auto kit = key_to_dir.find(key); + if (kit == key_to_dir.end()) { + continue; + } + auto st_it = m_snap_sync_stats.find(kit->second); + if (st_it == m_snap_sync_stats.end()) { + continue; + } + apply_persisted_dir_sync_stat(st_it->second, bl); + } +} + +void PeerReplayer::remove_persisted_dir_sync_stat(const std::string &dir_root) { + ceph_assert(m_local_ioctx); + + const std::string key = peer_sync_stat_omap_key(dir_root); + dout(5) << ": removing persisted sync stat omap key=" << key + << " for dir_root=" << dir_root << dendl; + librados::ObjectWriteOperation write_op; + write_op.omap_rm_keys({key}); + + Context *ctx = new C_RemovePersistedSyncStatAio(dir_root); + librados::AioCompletion *aio_comp = create_rados_callback(ctx); + int r = m_local_ioctx->aio_operate(CEPHFS_MIRROR_OBJECT, aio_comp, &write_op); + if (r < 0) { + delete ctx; + derr << ": failed to submit aio remove persisted sync stats for dir_root=" + << dir_root << ": " << cpp_strerror(r) << dendl; + aio_comp->release(); + return; + } + aio_comp->release(); +} + void PeerReplayer::add_live_sync_metrics_to_persist(json_spirit::mObject &obj, SnapSyncStat &sync_stat) { if (sync_stat.current_syncing_snap) { @@ -980,7 +1200,8 @@ int PeerReplayer::register_directory(const std::string &dir_root, return 0; } -void PeerReplayer::unregister_directory(const std::string &dir_root) { +void PeerReplayer::unregister_directory(const std::string &dir_root, + std::unique_lock &locker) { dout(20) << ": dir_root=" << dir_root << dendl; auto it = m_registered.find(dir_root); @@ -989,6 +1210,18 @@ void PeerReplayer::unregister_directory(const std::string &dir_root) { unlock_directory(it->first, it->second); m_registered.erase(it); if (std::find(m_directories.begin(), m_directories.end(), dir_root) == m_directories.end()) { + if (m_purging_directories.erase(dir_root)) { + remove_persisted_dir_sync_stat(dir_root); + } else { + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.current_syncing_snap = boost::none; + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_current_sync_perf_counters(dir_perf, sync_stat); + } + locker.unlock(); + persist_dir_sync_stat(dir_root); + locker.lock(); + } remove_directory_perf_counters(dir_root); m_snap_sync_stats.erase(dir_root); } @@ -2799,8 +3032,11 @@ int PeerReplayer::do_sync_snaps(const std::string &dir_root) { auto last = remote_snap_map.rbegin(); last_snap_id = last->first; last_snap_name = last->second; - set_last_synced_snap(dir_root, last_snap_id, last_snap_name); + + reconcile_last_synced_snap(dir_root, last_snap_id, last_snap_name); } + // Session counters reset on restart; persist unconditionally to clear stale state. + persist_dir_sync_stat(dir_root); dout(5) << ": last snap-id transferred=" << last_snap_id << dendl; auto it = local_snap_map.upper_bound(last_snap_id); @@ -2974,7 +3210,7 @@ void PeerReplayer::run(SnapshotReplayerThread *replayer) { m_perf_counters->inc(l_cephfs_mirror_peer_replayer_snap_sync_failures); } } - unregister_directory(*dir_root); + unregister_directory(*dir_root, locker); } } diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 08c214b2925..7339df9e7bf 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -11,6 +11,7 @@ #include "Types.h" #include "json_spirit/json_spirit.h" +#include #include #include @@ -38,7 +39,7 @@ public: void add_directory(std::string_view dir_root); // remove a directory from queue - void remove_directory(std::string_view dir_root); + void remove_directory(std::string_view dir_root, bool purging = false); // admin socket helpers void peer_status(Formatter *f); @@ -458,6 +459,32 @@ private: sync_stat.last_failed_reason = boost::none; } + void _reset_last_synced_snap_stat(const std::string &dir_root) { + auto &sync_stat = m_snap_sync_stats.at(dir_root); + sync_stat.last_synced = clock::zero(); + sync_stat.last_sync_duration.reset(); + sync_stat.last_sync_crawl_duration.reset(); + sync_stat.last_sync_datasync_queue_wait_duration.reset(); + sync_stat.last_sync_bytes.reset(); + sync_stat.last_sync_files.reset(); + } + + void reconcile_last_synced_snap(const std::string &dir_root, uint64_t snap_id, + const std::string &snap_name) { + std::scoped_lock locker(m_lock); + auto &sync_stat = m_snap_sync_stats.at(dir_root); + if (sync_stat.last_synced_snap && + sync_stat.last_synced_snap->first == snap_id && + sync_stat.last_synced_snap->second == snap_name) { + return; + } + _reset_last_synced_snap_stat(dir_root); + _set_last_synced_snap(dir_root, snap_id, snap_name); + if (auto *dir_perf = find_directory_perf_counters(dir_root)) { + update_directory_last_sync_perf_counters(dir_perf, sync_stat); + } + } + void _reset_sync_stat(const std::string &dir_root) { auto &sync_stat = m_snap_sync_stats.at(dir_root); sync_stat.sync_bytes = 0; @@ -656,6 +683,7 @@ private: std::map m_registered; std::vector m_directories; std::map m_snap_sync_stats; + std::set m_purging_directories; MountRef m_local_mount; ServiceDaemon *m_service_daemon; PeerReplayerAdminSocketHook *m_asok_hook = nullptr; @@ -710,11 +738,16 @@ private: boost::optional pick_directory(); int register_directory(const std::string &dir_root, SnapshotReplayerThread *replayer); - void unregister_directory(const std::string &dir_root); + void unregister_directory(const std::string &dir_root, + std::unique_lock &locker); int try_lock_directory(const std::string &dir_root, SnapshotReplayerThread *replayer, DirRegistry *registry); void unlock_directory(const std::string &dir_root, const DirRegistry ®istry); int sync_snaps(const std::string &dir_root, std::unique_lock &locker); + void load_persisted_dir_sync_stats(); + void load_persisted_dir_sync_stat(const std::string &dir_root); + void apply_persisted_dir_sync_stat(SnapSyncStat &sync_stat, const bufferlist &bl); + void remove_persisted_dir_sync_stat(const std::string &dir_root); void persist_dir_sync_stat(const std::string &dir_root); void add_live_sync_metrics_to_persist(json_spirit::mObject &obj, SnapSyncStat &sync_stat); From 51a8348dfd681dfd446c347a82e581f2ed52a4c6 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sat, 20 Jun 2026 13:28:56 +0530 Subject: [PATCH 373/596] tools/cephfs_mirror: Remove persisted dir stats When a directory is removed from mirroring, the persisted directory stats need to be removed. This patch handles the cleanup. Omap keys must not be removed when mirrored directories are reshuffled across cephfs-mirror daemons. The mgr release notify now carries a purging flag (set only during permanent removal, not reshuffle), and the daemon removes persisted stats only when purging is true. On reshuffle with an in-progress sync, clear live current_syncing_snap state and persist idle metrics so the acquiring daemon does not inherit stale syncing omap entries. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/snapshot_mirror.py | 14 +++++++++----- src/tools/cephfs_mirror/FSMirror.cc | 6 +++--- src/tools/cephfs_mirror/FSMirror.h | 6 +++--- src/tools/cephfs_mirror/InstanceWatcher.cc | 6 ++++-- src/tools/cephfs_mirror/InstanceWatcher.h | 2 +- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 884146109a4..237b12a945a 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -181,10 +181,12 @@ class FSPolicy: return json.dumps({'dir_path': dir_path, 'mode': 'acquire' }) - def release_message(dir_path): - return json.dumps({'dir_path': dir_path, - 'mode': 'release' - }) + def release_message(dir_path, purging=False): + msg = {'dir_path': dir_path, + 'mode': 'release'} + if purging: + msg['purging'] = True + return json.dumps(msg) with self.lock: if not self.dir_paths or self.stopping.is_set(): return @@ -211,7 +213,9 @@ class FSPolicy: elif action_type == ActionType.ACQUIRE: notifies[dir_path] = (lookup_info['instance_id'], acquire_message(dir_path)) elif action_type == ActionType.RELEASE: - notifies[dir_path] = (lookup_info['instance_id'], release_message(dir_path)) + notifies[dir_path] = (lookup_info['instance_id'], + release_message(dir_path, + lookup_info['purging'])) if update_map or removals: self.update_mapping(update_map, removals, callback=self.continue_action) for dir_path, message in notifies.items(): diff --git a/src/tools/cephfs_mirror/FSMirror.cc b/src/tools/cephfs_mirror/FSMirror.cc index ac19f158694..9b88fd55b86 100644 --- a/src/tools/cephfs_mirror/FSMirror.cc +++ b/src/tools/cephfs_mirror/FSMirror.cc @@ -390,8 +390,8 @@ void FSMirror::handle_acquire_directory(string_view dir_path) { } } -void FSMirror::handle_release_directory(string_view dir_path) { - dout(5) << ": dir_path=" << dir_path << dendl; +void FSMirror::handle_release_directory(string_view dir_path, bool purging) { + dout(5) << ": dir_path=" << dir_path << ", purging=" << purging << dendl; { std::scoped_lock locker(m_lock); @@ -402,7 +402,7 @@ void FSMirror::handle_release_directory(string_view dir_path) { m_directories.size()); for (auto &[peer, peer_replayer] : m_peer_replayers) { dout(10) << ": peer=" << peer << dendl; - peer_replayer->remove_directory(dir_path); + peer_replayer->remove_directory(dir_path, purging); } } if (m_perf_counters) { diff --git a/src/tools/cephfs_mirror/FSMirror.h b/src/tools/cephfs_mirror/FSMirror.h index 594049baa6b..7757373c3fa 100644 --- a/src/tools/cephfs_mirror/FSMirror.h +++ b/src/tools/cephfs_mirror/FSMirror.h @@ -117,8 +117,8 @@ private: fs_mirror->handle_acquire_directory(dir_path); } - void release_directory(std::string_view dir_path) override { - fs_mirror->handle_release_directory(dir_path); + void release_directory(std::string_view dir_path, bool purging) override { + fs_mirror->handle_release_directory(dir_path, purging); } }; @@ -189,7 +189,7 @@ private: void handle_shutdown_instance_watcher(int r); void handle_acquire_directory(std::string_view dir_path); - void handle_release_directory(std::string_view dir_path); + void handle_release_directory(std::string_view dir_path, bool purging); }; } // namespace mirror diff --git a/src/tools/cephfs_mirror/InstanceWatcher.cc b/src/tools/cephfs_mirror/InstanceWatcher.cc index 8cd7214b553..b8be5762497 100644 --- a/src/tools/cephfs_mirror/InstanceWatcher.cc +++ b/src/tools/cephfs_mirror/InstanceWatcher.cc @@ -87,21 +87,23 @@ void InstanceWatcher::handle_notify(uint64_t notify_id, uint64_t handle, std::string dir_path; std::string mode; + bool purging = false; try { JSONDecoder jd(bl); JSONDecoder::decode_json("dir_path", dir_path, &jd.parser, true); JSONDecoder::decode_json("mode", mode, &jd.parser, true); + JSONDecoder::decode_json("purging", purging, &jd.parser, false); } catch (const JSONDecoder::err &e) { derr << ": failed to decode notify json: " << e.what() << dendl; } dout(20) << ": notifier_id=" << notifier_id << ", dir_path=" << dir_path - << ", mode=" << mode << dendl; + << ", mode=" << mode << ", purging=" << purging << dendl; if (mode == "acquire") { m_listener.acquire_directory(dir_path); } else if (mode == "release") { - m_listener.release_directory(dir_path); + m_listener.release_directory(dir_path, purging); } else { derr << ": unknown mode" << dendl; } diff --git a/src/tools/cephfs_mirror/InstanceWatcher.h b/src/tools/cephfs_mirror/InstanceWatcher.h index 5a48085d28c..fd939a1af81 100644 --- a/src/tools/cephfs_mirror/InstanceWatcher.h +++ b/src/tools/cephfs_mirror/InstanceWatcher.h @@ -27,7 +27,7 @@ public: } virtual void acquire_directory(std::string_view dir_path) = 0; - virtual void release_directory(std::string_view dir_path) = 0; + virtual void release_directory(std::string_view dir_path, bool purging) = 0; }; static InstanceWatcher *create(librados::IoCtx &ioctx, From 33c572242ba78924df3e65f9fec23f2aefe81c66 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Wed, 29 Apr 2026 14:49:15 +0530 Subject: [PATCH 374/596] mgr/mirroring: Add new interface to expose mirroring metrics Add the following new interface to expose mirroring metrics ceph fs snapshot mirror status [] [--peer_uuid=] The cmd loads the persisted directory sync metrics from the cephfs_mirror object's omap. Metrics are grouped by mirrored directory and peer. When --peer_uuid is specified, only metrics for that peer are returned. peer_uuid is a named CLI argument (_end_positional_) so peer-only filtering does not require a mirrored directory path. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- .../mgr/mirroring/fs/metrics/__init__.py | 0 src/pybind/mgr/mirroring/fs/metrics/load.py | 96 +++++++++++++++++++ .../mgr/mirroring/fs/snapshot_mirror.py | 75 +++++++++++---- src/pybind/mgr/mirroring/fs/utils.py | 36 +++++++ src/pybind/mgr/mirroring/module.py | 10 ++ 5 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/pybind/mgr/mirroring/fs/metrics/__init__.py create mode 100644 src/pybind/mgr/mirroring/fs/metrics/load.py diff --git a/src/pybind/mgr/mirroring/fs/metrics/__init__.py b/src/pybind/mgr/mirroring/fs/metrics/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py new file mode 100644 index 00000000000..8eff6a5c819 --- /dev/null +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -0,0 +1,96 @@ +import errno +import json +import logging +from typing import Any, Dict + +import rados + +from ..dir_map.load import MAX_RETURN +from ..exception import MirrorException +from ..utils import ( + MIRROR_OBJECT_NAME, + SYNC_STAT_KEY_PREFIX, + decode_sync_stat_val, + get_metadata_pool, + parse_sync_stat_omap_key, +) + +log = logging.getLogger(__name__) + + +def nest_sync_stat_in_metrics(metrics, dir_path, peer_uuid, stat): + metrics.setdefault(dir_path, {}).setdefault('peer', {})[peer_uuid] = stat + + +def load_sync_stat_by_keys(ioctx, keys): + stats: Dict[str, Any] = {} + if not keys: + return stats + try: + with rados.ReadOpCtx() as read_op: + it, ret = ioctx.get_omap_vals_by_keys(read_op, keys) + if ret != 0: + log.error('failed to read sync stat omap keys') + raise MirrorException(-errno.EINVAL, + 'failed to read sync stat omap keys') + ioctx.operate_read_op(read_op, MIRROR_OBJECT_NAME) + for key, val in it: + try: + stats[key] = decode_sync_stat_val(val) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + log.warning(f'failed to decode sync stat for key {key}: {e}') + except rados.Error as e: + log.error(f'failed to read sync stat omap keys: {e}') + raise MirrorException(-e.errno, 'failed to read sync stat omap keys') + return stats + + +def open_metadata_ioctx(rados_inst, fs_map, filesystem): + metadata_pool_id = get_metadata_pool(filesystem, fs_map) + if not metadata_pool_id: + raise MirrorException(-errno.EINVAL, + f'cannot find metadata pool for filesystem {filesystem}') + try: + return rados_inst.open_ioctx2(metadata_pool_id) + except rados.Error as e: + log.error(f'failed to open metadata pool for {filesystem}: {e}') + raise MirrorException(-e.errno, + f'failed to open metadata pool for {filesystem}') + + +def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): + metrics: Dict[str, Any] = {} + prefix = f'{SYNC_STAT_KEY_PREFIX}/{filesystem}/' + if peer_uuid: + prefix += f'{peer_uuid}/' + try: + with rados.ReadOpCtx() as read_op: + start = "" + while True: + it, ret = ioctx.get_omap_vals(read_op, start, prefix, MAX_RETURN) + if ret != 0: + log.error('failed to read sync stat omap') + raise MirrorException(-errno.EINVAL, + 'failed to read sync stat omap') + ioctx.operate_read_op(read_op, MIRROR_OBJECT_NAME) + omap_vals = dict(it) + if not omap_vals: + break + for key, val in omap_vals.items(): + parsed = parse_sync_stat_omap_key(key, filesystem) + if not parsed: + continue + peer, dir_path = parsed + if peer_uuid and peer != peer_uuid: + continue + try: + stat = decode_sync_stat_val(val) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + log.warning(f'failed to decode sync stat for key {key}: {e}') + continue + nest_sync_stat_in_metrics(metrics, dir_path, peer, stat) + start = omap_vals.popitem()[0] + except rados.Error as e: + log.error(f'failed to read sync stat omap: {e}') + raise MirrorException(-e.errno, 'failed to read sync stat omap') + return metrics diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 237b12a945a..e25a0b19458 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -19,7 +19,10 @@ from mgr_module import NotifyType from .blocklist import blocklist from .notify import Notifier, InstanceWatcher from .utils import INSTANCE_ID_PREFIX, MIRROR_OBJECT_NAME, Finisher, \ - AsyncOpTracker, connect_to_filesystem, disconnect_from_filesystem + AsyncOpTracker, get_metadata_pool, norm_path, connect_to_filesystem, \ + disconnect_from_filesystem, sync_stat_omap_key +from .metrics import load as metrics_load +from .metrics.load import nest_sync_stat_in_metrics from .exception import MirrorException from .dir_map.create import create_mirror_object from .dir_map.load import load_dir_map, load_instances @@ -313,13 +316,6 @@ class FSSnapshotMirror: except ValueError: raise MirrorException(-errno.EINVAL, f'invalid cluster spec {spec}') - @staticmethod - def get_metadata_pool(filesystem, fs_map): - for fs in fs_map['filesystems']: - if fs['mdsmap']['fs_name'] == filesystem: - return fs['mdsmap']['metadata_pool'] - return None - @staticmethod def get_filesystem_id(filesystem, fs_map): for fs in fs_map['filesystems']: @@ -483,7 +479,7 @@ class FSSnapshotMirror: disconnect_from_filesystem(cluster_name, remote_fs_name, remote_cluster, remote_fs) def init_pool_policy(self, filesystem): - metadata_pool_id = FSSnapshotMirror.get_metadata_pool(filesystem, self.fs_map) + metadata_pool_id = get_metadata_pool(filesystem, self.fs_map) if not metadata_pool_id: log.error(f'cannot find metadata pool-id for filesystem {filesystem}') raise Exception(-errno.EINVAL) @@ -522,7 +518,7 @@ class FSSnapshotMirror: log.info(f'enabling mirror for filesystem {filesystem}') with self.lock: try: - metadata_pool_id = FSSnapshotMirror.get_metadata_pool(filesystem, self.fs_map) + metadata_pool_id = get_metadata_pool(filesystem, self.fs_map) if not metadata_pool_id: log.error(f'cannot find metadata pool-id for filesystem {filesystem}') raise Exception(-errno.EINVAL) @@ -690,12 +686,6 @@ class FSSnapshotMirror: remote_cluster_spec = f'{client_name}@{cluster_name}' return self.peer_add(filesystem, remote_cluster_spec, remote_fs_name, token_dct) - @staticmethod - def norm_path(dir_path): - if not os.path.isabs(dir_path): - raise MirrorException(-errno.EINVAL, f'{dir_path} should be an absolute path') - return os.path.normpath(dir_path) - def add_dir(self, filesystem, dir_path): try: with self.lock: @@ -704,7 +694,7 @@ class FSSnapshotMirror: fspolicy = self.pool_policy.get(filesystem, None) if not fspolicy: raise MirrorException(-errno.EINVAL, f'filesystem {filesystem} is not mirrored') - dir_path = FSSnapshotMirror.norm_path(dir_path) + dir_path = norm_path(dir_path) log.debug(f'path normalized to {dir_path}') fspolicy.add_dir(dir_path) return 0, json.dumps({}), '' @@ -721,7 +711,7 @@ class FSSnapshotMirror: fspolicy = self.pool_policy.get(filesystem, None) if not fspolicy: raise MirrorException(-errno.EINVAL, f'filesystem {filesystem} is not mirrored') - dir_path = FSSnapshotMirror.norm_path(dir_path) + dir_path = norm_path(dir_path) fspolicy.remove_dir(dir_path) return 0, json.dumps({}), '' except MirrorException as me: @@ -751,7 +741,7 @@ class FSSnapshotMirror: fspolicy = self.pool_policy.get(filesystem, None) if not fspolicy: raise MirrorException(-errno.EINVAL, f'filesystem {filesystem} is not mirrored') - dir_path = FSSnapshotMirror.norm_path(dir_path) + dir_path = norm_path(dir_path) return fspolicy.status(dir_path) except MirrorException as me: return me.args[0], '', me.args[1] @@ -768,6 +758,53 @@ class FSSnapshotMirror: except MirrorException as me: return me.args[0], '', me.args[1] + def metrics_status(self, filesystem, mirrored_dir_path, peer_uuid): + """Return persisted mirror directory snapshot metrics as JSON""" + try: + with self.lock: + if not self.filesystem_exist(filesystem): + raise MirrorException(-errno.ENOENT, + f'filesystem {filesystem} does not exist') + fspolicy = self.pool_policy.get(filesystem, None) + if not fspolicy: + raise MirrorException(-errno.EINVAL, + f'filesystem {filesystem} is not mirrored') + if mirrored_dir_path: + dir_path = norm_path(mirrored_dir_path) + if not fspolicy.policy.lookup(dir_path): + raise MirrorException(-errno.ENOENT, + f'directory {dir_path} is not mirrored') + peers = self.get_filesystem_peers(filesystem) + if not peers: + return 0, json.dumps({}), '' + + if peer_uuid: + if peer_uuid not in peers: + raise MirrorException(-errno.ENOENT, + f'peer {peer_uuid} not found for ' + f'filesystem {filesystem}') + peers = {peer_uuid: peers[peer_uuid]} + + ioctx = metrics_load.open_metadata_ioctx( + self.rados, self.fs_map, filesystem) + if mirrored_dir_path: + dir_path = norm_path(mirrored_dir_path) + keys = [sync_stat_omap_key(filesystem, peer, dir_path) + for peer in peers] + omap_stats = metrics_load.load_sync_stat_by_keys(ioctx, keys) + metrics = {} + for peer in peers: + omap_key = sync_stat_omap_key(filesystem, peer, dir_path) + if omap_key in omap_stats: + nest_sync_stat_in_metrics(metrics, dir_path, peer, + omap_stats[omap_key]) + else: + metrics = metrics_load.load_sync_stat_metrics( + ioctx, filesystem, peer_uuid) + return 0, json.dumps(metrics, indent=4), '' + except MirrorException as me: + return me.args[0], '', me.args[1] + def daemon_status(self, format='json'): try: with self.lock: diff --git a/src/pybind/mgr/mirroring/fs/utils.py b/src/pybind/mgr/mirroring/fs/utils.py index 5e1d0537356..a67e6c06a7f 100644 --- a/src/pybind/mgr/mirroring/fs/utils.py +++ b/src/pybind/mgr/mirroring/fs/utils.py @@ -1,5 +1,7 @@ import errno +import json import logging +import os import threading import rados @@ -12,9 +14,43 @@ MIRROR_OBJECT_NAME = MIRROR_OBJECT_PREFIX INSTANCE_ID_PREFIX = "instance_" DIRECTORY_MAP_PREFIX = "dir_map_" +SYNC_STAT_KEY_PREFIX = "sync_stat" log = logging.getLogger(__name__) + +def get_metadata_pool(filesystem, fs_map): + for fs in fs_map['filesystems']: + if fs['mdsmap']['fs_name'] == filesystem: + return fs['mdsmap']['metadata_pool'] + return None + + +def norm_path(dir_path): + if not os.path.isabs(dir_path): + raise MirrorException(-errno.EINVAL, f'{dir_path} should be an absolute path') + return os.path.normpath(dir_path) + + +def sync_stat_omap_key(filesystem, peer_uuid, dir_path): + return (f'{SYNC_STAT_KEY_PREFIX}/{filesystem}/{peer_uuid}/' + f'{dir_path.lstrip("/")}') + + +def parse_sync_stat_omap_key(key, filesystem): + prefix = f'{SYNC_STAT_KEY_PREFIX}/{filesystem}/' + if not key.startswith(prefix): + return None + suffix = key[len(prefix):] + peer_uuid, sep, dir_rel = suffix.partition('/') + if not peer_uuid or not sep: + return None + return peer_uuid, os.path.normpath('/' + dir_rel) + + +def decode_sync_stat_val(val): + return json.loads(val.decode('utf-8')) + def connect_to_cluster(client_name, cluster_name, conf_dct, desc=''): try: log.debug(f'connecting to {desc} cluster: {client_name}/{cluster_name}') diff --git a/src/pybind/mgr/mirroring/module.py b/src/pybind/mgr/mirroring/module.py index 4a9813c6df9..1321e27f749 100644 --- a/src/pybind/mgr/mirroring/module.py +++ b/src/pybind/mgr/mirroring/module.py @@ -116,3 +116,13 @@ class Module(MgrModule): format: str = 'json'): """Get mirror daemon status""" return self.fs_snapshot_mirror.daemon_status(format) + + @MirroringCLICommand.Read('fs snapshot mirror status') + def snapshot_mirror_status(self, + fs_name: str, + mirrored_dir_path: Optional[str] = None, + _end_positional_: int = 0, + peer_uuid: Optional[str] = None): + """Get snapshot mirror metrics for a filesystem (optional mirrored directory and peer)""" + return self.fs_snapshot_mirror.metrics_status(fs_name, mirrored_dir_path, + peer_uuid) From 4c19167719caeab7de059bb1ba983e3f16afb5b3 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Mon, 1 Jun 2026 14:59:31 +0530 Subject: [PATCH 375/596] mgr/mirroring: Reorder and format metrics output Reorder and format status output to match the output of asok interface peer_status command. Return metrics under metrics//peer/ to match the asok peer_status layout, including {"metrics": {}} when no peers are configured. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/metrics/format.py | 113 ++++++++++++++++++ src/pybind/mgr/mirroring/fs/metrics/load.py | 9 +- .../mgr/mirroring/fs/snapshot_mirror.py | 13 +- 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 src/pybind/mgr/mirroring/fs/metrics/format.py diff --git a/src/pybind/mgr/mirroring/fs/metrics/format.py b/src/pybind/mgr/mirroring/fs/metrics/format.py new file mode 100644 index 00000000000..b208151c167 --- /dev/null +++ b/src/pybind/mgr/mirroring/fs/metrics/format.py @@ -0,0 +1,113 @@ +# Matches the format_time function used in peer_status +def _format_time(total_seconds_d): + total_seconds = int(round(total_seconds_d)) + days = total_seconds // 86400 + total_seconds %= 86400 + hours = total_seconds // 3600 + total_seconds %= 3600 + minutes = total_seconds // 60 + seconds = total_seconds % 60 + if days > 0: + return (f'{days}d {hours:02d}h {minutes:02d}m {seconds:02d}s') + if hours > 0: + return f'{hours}h {minutes:02d}m {seconds:02d}s' + if minutes > 0: + return f'{minutes}m {seconds:02d}s' + return f'{seconds}s' + + +# Matches the format_bytes function used in peer_status +def _format_bytes(bytes_val): + kib = 1024.0 + mib = kib * 1024.0 + gib = mib * 1024.0 + tib = gib * 1024.0 + pib = tib * 1024.0 + if bytes_val >= pib: + return f'{bytes_val / pib:.2f} PiB' + if bytes_val >= tib: + return f'{bytes_val / tib:.2f} TiB' + if bytes_val >= gib: + return f'{bytes_val / gib:.2f} GiB' + if bytes_val >= mib: + return f'{bytes_val / mib:.2f} MiB' + if bytes_val >= kib: + return f'{bytes_val / kib:.2f} KiB' + return f'{bytes_val:.2f} B' + + +def _order_dict(d, keys): + ordered = {} + for key in keys: + if key in d: + ordered[key] = d[key] + for key, val in d.items(): + if key not in ordered: + ordered[key] = val + return ordered + + +def _order_current_syncing_snap(snap): + if not isinstance(snap, dict): + return snap + snap = dict(snap) + crawl = snap.get('crawl') + if isinstance(crawl, dict): + snap['crawl'] = _order_dict(crawl, ('state', 'duration')) + dq_wait = snap.get('datasync_queue_wait') + if isinstance(dq_wait, dict): + snap['datasync_queue_wait'] = _order_dict(dq_wait, ('state', 'duration')) + bytes_obj = snap.get('bytes') + if isinstance(bytes_obj, dict): + snap['bytes'] = _order_dict( + bytes_obj, ('sync_bytes', 'total_bytes', 'sync_percent')) + files_obj = snap.get('files') + if isinstance(files_obj, dict): + snap['files'] = _order_dict( + files_obj, ('sync_files', 'total_files', 'sync_percent')) + return _order_dict( + snap, ('id', 'name', 'sync-mode', 'avg_read_throughput_bytes', + 'avg_write_throughput_bytes', 'crawl', 'datasync_queue_wait', + 'bytes', 'files', 'eta')) + + +def format_peer_status_metrics(metrics, dir_path, peer_uuid, stat): + metrics.setdefault(dir_path, {}).setdefault('peer', {})[peer_uuid] = stat + + +# to match the output of peer_status +def format_and_order_sync_stat_for_display(stat): + if not isinstance(stat, dict): + return stat + + out = dict(stat) + last_synced_snap = out.get('last_synced_snap') + if isinstance(last_synced_snap, dict): + snap = dict(last_synced_snap) + for key in ('crawl_duration', 'datasync_queue_wait_duration', + 'sync_duration'): + val = snap.get(key) + if isinstance(val, (int, float)): + snap[key] = _format_time(val) + sync_bytes = snap.get('sync_bytes') + if isinstance(sync_bytes, (int, float)): + snap['sync_bytes'] = _format_bytes(sync_bytes) + sync_time_stamp = snap.get('sync_time_stamp') + if isinstance(sync_time_stamp, (int, float)): + snap['sync_time_stamp'] = f'{sync_time_stamp:.6f}s' + out['last_synced_snap'] = _order_dict( + snap, ('id', 'name', 'crawl_duration', + 'datasync_queue_wait_duration', 'sync_duration', + 'sync_time_stamp', 'sync_bytes', 'sync_files')) + + current_syncing_snap = out.get('current_syncing_snap') + if isinstance(current_syncing_snap, dict): + out['current_syncing_snap'] = _order_current_syncing_snap( + current_syncing_snap) + + out.pop('_instance_id', None) + + return _order_dict( + out, ('state', 'failure_reason', 'current_syncing_snap', + 'last_synced_snap', 'snaps_synced', 'snaps_deleted', + 'snaps_renamed', 'metrics_updated_at')) diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py index 8eff6a5c819..f5aed3a28a5 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/load.py +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -14,14 +14,11 @@ from ..utils import ( get_metadata_pool, parse_sync_stat_omap_key, ) +from .format import format_and_order_sync_stat_for_display, format_peer_status_metrics log = logging.getLogger(__name__) -def nest_sync_stat_in_metrics(metrics, dir_path, peer_uuid, stat): - metrics.setdefault(dir_path, {}).setdefault('peer', {})[peer_uuid] = stat - - def load_sync_stat_by_keys(ioctx, keys): stats: Dict[str, Any] = {} if not keys: @@ -88,7 +85,9 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): except (json.JSONDecodeError, UnicodeDecodeError) as e: log.warning(f'failed to decode sync stat for key {key}: {e}') continue - nest_sync_stat_in_metrics(metrics, dir_path, peer, stat) + format_peer_status_metrics( + metrics, dir_path, peer, + format_and_order_sync_stat_for_display(stat)) start = omap_vals.popitem()[0] except rados.Error as e: log.error(f'failed to read sync stat omap: {e}') diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index e25a0b19458..1504b8831ef 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -22,7 +22,8 @@ from .utils import INSTANCE_ID_PREFIX, MIRROR_OBJECT_NAME, Finisher, \ AsyncOpTracker, get_metadata_pool, norm_path, connect_to_filesystem, \ disconnect_from_filesystem, sync_stat_omap_key from .metrics import load as metrics_load -from .metrics.load import nest_sync_stat_in_metrics +from .metrics.format import format_and_order_sync_stat_for_display, \ + format_peer_status_metrics from .exception import MirrorException from .dir_map.create import create_mirror_object from .dir_map.load import load_dir_map, load_instances @@ -776,7 +777,7 @@ class FSSnapshotMirror: f'directory {dir_path} is not mirrored') peers = self.get_filesystem_peers(filesystem) if not peers: - return 0, json.dumps({}), '' + return 0, json.dumps({'metrics': {}}, indent=4), '' if peer_uuid: if peer_uuid not in peers: @@ -796,12 +797,14 @@ class FSSnapshotMirror: for peer in peers: omap_key = sync_stat_omap_key(filesystem, peer, dir_path) if omap_key in omap_stats: - nest_sync_stat_in_metrics(metrics, dir_path, peer, - omap_stats[omap_key]) + format_peer_status_metrics( + metrics, dir_path, peer, + format_and_order_sync_stat_for_display( + omap_stats[omap_key])) else: metrics = metrics_load.load_sync_stat_metrics( ioctx, filesystem, peer_uuid) - return 0, json.dumps(metrics, indent=4), '' + return 0, json.dumps({'metrics': metrics}, indent=4), '' except MirrorException as me: return me.args[0], '', me.args[1] From 8647c2ee79649b002c23bfb88757955640f886ce Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 16 Jun 2026 16:12:41 +0530 Subject: [PATCH 376/596] mgr/mirroring: cache fs snapshot mirror status omap metrics Add a short-lived in-memory cache for metrics returned by "ceph fs snapshot mirror status", which reads persisted sync stats from the cephfs_mirror object omap. Omap walks are relatively expensive; caching reduces repeated reads when the CLI or multiple clients poll at short intervals. Two TTL-bucketed LRU caches (CACHE_TTL_SECS) are used instead of one unified cache. The complete cache always holds every mirrored directory and peer for a filesystem; the partial cache holds one directory. A single directory-granularity cache cannot prove it has all directories, so full-scan queries rely on the complete cache contract. Cache implementation (lru_cache_timeout decorator backed by _TimedLRUCache, not functools.lru_cache): - lru_cache has no TTL and no peek-on-hit API. Single-directory queries must read the complete cache without loading on miss; lru_cache.cache is also unavailable on Python 3.14+. - complete (sync_stat_complete_cache): full omap prefix scan via load_sync_stat_metrics. Cache key: (time_token, filesystem). COMPLETE_CACHE_MAX limits filesystem entries per TTL window. - partial (sync_stat_partial_cache): per-directory omap key load via fetch_sync_stat_metrics. Cache key: (time_token, filesystem, dir_path, peer_ids). PARTIAL_CACHE_MAX limits directory entries. Bind cache_peek/cache_info/cache_clear via a CachedMethod descriptor so TTL lookups receive (self, ...); otherwise single-dir status fails with 'str' object has no attribute 'mgr'. Serve logic (under the existing lock): - status [--peer_uuid=]: load complete cache on miss; filter by peer when requested. - status : peek complete cache (no load on miss); if the entry contains the directory and peers, serve from complete. Otherwise load partial cache (omap on miss). Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/metrics/cache.py | 202 ++++++++++++++++++ src/pybind/mgr/mirroring/fs/metrics/load.py | 21 ++ .../mgr/mirroring/fs/snapshot_mirror.py | 126 +++++++++-- 3 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 src/pybind/mgr/mirroring/fs/metrics/cache.py diff --git a/src/pybind/mgr/mirroring/fs/metrics/cache.py b/src/pybind/mgr/mirroring/fs/metrics/cache.py new file mode 100644 index 00000000000..0fcfdbd7e45 --- /dev/null +++ b/src/pybind/mgr/mirroring/fs/metrics/cache.py @@ -0,0 +1,202 @@ +from collections import OrderedDict, namedtuple +from functools import wraps +from typing import Any, Callable, Dict + +import time + +from ..utils import norm_path +from .format import format_and_order_sync_stat_for_display, format_peer_status_metrics + +# Two caches are used instead of one unified cache: +# +# - Complete cache holds a full omap snapshot (all dirs, all peers) per +# filesystem. A hit guarantees the entry is complete, so full-scan queries +# ('status ', 'status ') can be served without doubt. +# +# - Partial cache holds per-directory omap loads. Single-dir queries +# ('status ') peek the complete cache first; on miss they use +# partial cache to avoid a full omap scan. +# +# A single cache keyed at directory granularity cannot cleanly answer "do I +# have every directory?" — partial entries may be present while others are +# missing, so serving a full scan from cache would require extra bookkeeping +# to prove completeness. Splitting complete vs partial makes that contract +# explicit: complete = all dirs; partial = one dir. + +# LRU max entries for complete-cache lookups (one entry per filesystem per TTL +# window; each entry holds all dirs and all peers for that filesystem). +# Cache key: (time_token, filesystem). +COMPLETE_CACHE_MAX = 8 +# LRU max entries for partial-cache lookups (per-directory omap batch load). +# Cache key: (time_token, filesystem, dir_path, peer_ids). +PARTIAL_CACHE_MAX = 32 + +CacheInfo = namedtuple('CacheInfo', ['hits', 'misses', 'maxsize', 'currsize']) + + +# functools.lru_cache is not sufficient here for two reasons: +# +# 1. TTL: entries must expire after snapshot_mirror_metrics_cache_ttl. lru_cache +# has no TTL, so we bucket keys with a time_token derived from monotonic time. +# +# 2. cache_peek: single-directory status queries must read the complete cache +# without loading on miss (otherwise a cold complete cache would trigger a +# full omap scan). lru_cache has no peek API; calling the wrapper always +# invokes the loader on miss. Inspecting lru_cache.cache for a hit-only +# lookup is also not viable: that attribute is not part of the public API and +# is unavailable on Python 3.14+ (_lru_cache_wrapper has no .cache). +# +# _TimedLRUCache therefore implements TTL-bucketed LRU storage plus peek(). +class _TimedLRUCache: + def __init__(self, func, maxsize): + self.func = func + self.maxsize = maxsize + self.store = OrderedDict() + self.hits = 0 + self.misses = 0 + + def _key(self, time_token, args): + # time_token buckets entries by snapshot_mirror_metrics_cache_ttl; args are + # the decorated method's positional arguments (e.g. filesystem, or + # filesystem+dir+peers). + return (time_token,) + args + + def get(self, time_token, args, load=True): + key = self._key(time_token, args) + if key in self.store: + self.hits += 1 + self.store.move_to_end(key) + return self.store[key] + if not load: + return None + self.misses += 1 + val = self.func(*args) + self.store[key] = val + while len(self.store) > self.maxsize: + self.store.popitem(last=False) + return val + + def peek(self, time_token, args): + return self.get(time_token, args, load=False) + + def clear(self): + self.store.clear() + self.hits = 0 + self.misses = 0 + + def info(self): + return CacheInfo(self.hits, self.misses, self.maxsize, len(self.store)) + + +def _resolve_ttl(ttl_getter, args, kwargs): + try: + return ttl_getter(*args, **kwargs) + except TypeError: + return ttl_getter() + + +def lru_cache_timeout(ttl_getter: Callable[..., int], maxsize: int = 128): + def decorator(func): + cache = _TimedLRUCache(func, maxsize) + + # BoundCached is returned from CachedMethod.__get__ so cache_peek, + # cache_info, and cache_clear are bound to the instance. Attaching + # those helpers to a plain wrapper function would drop self (e.g. + # self.sync_stat_complete_cache.cache_peek(fs) would pass only fs), + # breaking ttl_getter lambdas that use self.mgr. + class BoundCached: + def __init__(self, instance): + self._instance = instance + + def _full_args(self, *args): + return (self._instance,) + args + + def __call__(self, *args, **kwargs): + full = self._full_args(*args) + ttl = _resolve_ttl(ttl_getter, full, kwargs) + if not ttl: + return func(*full, **kwargs) + time_token = int(time.monotonic() / ttl) + return cache.get(time_token, full) + + def cache_peek(self, *args, **kwargs): + full = self._full_args(*args) + ttl = _resolve_ttl(ttl_getter, full, kwargs) + if not ttl: + return None + time_token = int(time.monotonic() / ttl) + return cache.peek(time_token, full) + + def cache_clear(self): + cache.clear() + + def cache_info(self): + return cache.info() + + # Descriptor: self.method(...) and self.method.cache_peek(...) share + # the same BoundCached instance and cache keys. + class CachedMethod: + def __get__(self, obj, owner=None): + if obj is None: + return self + return BoundCached(obj) + + def __call__(self, *args, **kwargs): + ttl = _resolve_ttl(ttl_getter, args, kwargs) + if not ttl: + return func(*args, **kwargs) + time_token = int(time.monotonic() / ttl) + return cache.get(time_token, args) + + cached = CachedMethod() + wraps(func)(cached) + return cached + return decorator + + +def _peer_stats_for_dir(metrics, dir_path): + return metrics.get(dir_path, {}).get('peer', {}) + + +def _requested_peers_have_stats(metrics, peers, dir_path): + peer_stats = _peer_stats_for_dir(metrics, dir_path) + return all(peer in peer_stats for peer in peers) + + +def metrics_for_dir_and_peers(metrics, dir_path, peers): + result: Dict[str, Any] = {} + peer_stats = _peer_stats_for_dir(metrics, dir_path) + for peer in peers: + if peer in peer_stats: + format_peer_status_metrics( + result, dir_path, peer, + format_and_order_sync_stat_for_display(peer_stats[peer])) + return result + + +def metrics_for_peer(metrics, peer_uuid): + result: Dict[str, Any] = {} + for dir_path, dir_entry in metrics.items(): + peer_stats = dir_entry.get('peer', {}) + if peer_uuid in peer_stats: + format_peer_status_metrics( + result, dir_path, peer_uuid, + format_and_order_sync_stat_for_display(peer_stats[peer_uuid])) + return result + + +def try_get_from_complete(metrics, mirrored_dir_path, peer_uuid, peers): + """Filter complete cache (all dirs, all peers) for the CLI request. + + The complete cache always holds every peer and directory. peer_uuid and + peers select the subset to return; they do not affect what is loaded. + """ + if mirrored_dir_path: + dir_path = norm_path(mirrored_dir_path) + if _requested_peers_have_stats(metrics, peers, dir_path): + return metrics_for_dir_and_peers(metrics, dir_path, peers) + return None + + if peer_uuid is None: + return dict(metrics) + return metrics_for_peer(metrics, peer_uuid) diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py index f5aed3a28a5..d157b36b7c2 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/load.py +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -12,7 +12,9 @@ from ..utils import ( SYNC_STAT_KEY_PREFIX, decode_sync_stat_val, get_metadata_pool, + norm_path, parse_sync_stat_omap_key, + sync_stat_omap_key, ) from .format import format_and_order_sync_stat_for_display, format_peer_status_metrics @@ -93,3 +95,22 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): log.error(f'failed to read sync stat omap: {e}') raise MirrorException(-e.errno, 'failed to read sync stat omap') return metrics + + +def fetch_sync_stat_metrics(ioctx, filesystem, peers, mirrored_dir_path, peer_uuid): + if mirrored_dir_path: + dir_path = norm_path(mirrored_dir_path) + keys = [sync_stat_omap_key(filesystem, peer, dir_path) for peer in peers] + omap_stats = load_sync_stat_by_keys(ioctx, keys) + metrics: Dict[str, Any] = {} + for peer in peers: + omap_key = sync_stat_omap_key(filesystem, peer, dir_path) + stat = omap_stats.get(omap_key) + if stat is not None: + format_peer_status_metrics( + metrics, dir_path, peer, + format_and_order_sync_stat_for_display(stat)) + return metrics, False, dir_path + + metrics = load_sync_stat_metrics(ioctx, filesystem, peer_uuid) + return metrics, True, None diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 1504b8831ef..2b1fa76425e 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -20,10 +20,11 @@ from .blocklist import blocklist from .notify import Notifier, InstanceWatcher from .utils import INSTANCE_ID_PREFIX, MIRROR_OBJECT_NAME, Finisher, \ AsyncOpTracker, get_metadata_pool, norm_path, connect_to_filesystem, \ - disconnect_from_filesystem, sync_stat_omap_key + disconnect_from_filesystem +from .metrics.cache import ( + CACHE_TTL_SECS, COMPLETE_CACHE_MAX, lru_cache_timeout, PARTIAL_CACHE_MAX, + metrics_for_dir_and_peers, try_get_from_complete) from .metrics import load as metrics_load -from .metrics.format import format_and_order_sync_stat_for_display, \ - format_peer_status_metrics from .exception import MirrorException from .dir_map.create import create_mirror_object from .dir_map.load import load_dir_map, load_instances @@ -759,8 +760,52 @@ class FSSnapshotMirror: except MirrorException as me: return me.args[0], '', me.args[1] + @lru_cache_timeout( + lambda self, *_args, **_kwargs: CACHE_TTL_SECS, + COMPLETE_CACHE_MAX) + def sync_stat_complete_cache(self, filesystem): + """Load all directories and all peers for a filesystem from omap. + + Cache key: (time_token, filesystem). Each entry stores the full omap + snapshot for that filesystem (all dirs, all peers). A hit means every + directory is present, so full-scan queries can be served safely. + Used for 'status ' and 'status --peer_uuid='; peer + filtering is applied when serving. + """ + log.debug('sync stat metrics for filesystem %s loaded from omap (complete)', + filesystem) + ioctx = metrics_load.open_metadata_ioctx( + self.rados, self.fs_map, filesystem) + return metrics_load.load_sync_stat_metrics(ioctx, filesystem) + + @lru_cache_timeout( + lambda self, *_args, **_kwargs: CACHE_TTL_SECS, + PARTIAL_CACHE_MAX) + def sync_stat_partial_cache(self, filesystem, dir_path, peer_ids): + """Load sync-stat omap keys for one directory and a peer set. + + Cache key: (time_token, filesystem, dir_path, peer_ids). Used as a + fallback for 'status ' when the complete cache is cold or + does not contain the requested directory. + """ + peer_scope = (next(iter(peer_ids)) if len(peer_ids) == 1 else '*') + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'loaded from omap', + filesystem, dir_path, peer_scope) + peers = {peer_id: None for peer_id in peer_ids} + ioctx = metrics_load.open_metadata_ioctx( + self.rados, self.fs_map, filesystem) + metrics, _, _ = metrics_load.fetch_sync_stat_metrics( + ioctx, filesystem, peers, dir_path, None) + return metrics + def metrics_status(self, filesystem, mirrored_dir_path, peer_uuid): - """Return persisted mirror directory snapshot metrics as JSON""" + """Return persisted mirror directory snapshot metrics as JSON. + + Uses two caches (see metrics/cache.py): complete for full-scan queries + where a hit proves all dirs are present; partial for single-dir + queries when the complete cache is cold or lacks that directory. + """ try: with self.lock: if not self.filesystem_exist(filesystem): @@ -775,35 +820,70 @@ class FSSnapshotMirror: if not fspolicy.policy.lookup(dir_path): raise MirrorException(-errno.ENOENT, f'directory {dir_path} is not mirrored') - peers = self.get_filesystem_peers(filesystem) - if not peers: + all_peers = self.get_filesystem_peers(filesystem) + if not all_peers: return 0, json.dumps({'metrics': {}}, indent=4), '' if peer_uuid: - if peer_uuid not in peers: + if peer_uuid not in all_peers: raise MirrorException(-errno.ENOENT, f'peer {peer_uuid} not found for ' f'filesystem {filesystem}') - peers = {peer_uuid: peers[peer_uuid]} + requested_peers = {peer_uuid: all_peers[peer_uuid]} + else: + requested_peers = all_peers - ioctx = metrics_load.open_metadata_ioctx( - self.rados, self.fs_map, filesystem) if mirrored_dir_path: dir_path = norm_path(mirrored_dir_path) - keys = [sync_stat_omap_key(filesystem, peer, dir_path) - for peer in peers] - omap_stats = metrics_load.load_sync_stat_by_keys(ioctx, keys) - metrics = {} - for peer in peers: - omap_key = sync_stat_omap_key(filesystem, peer, dir_path) - if omap_key in omap_stats: - format_peer_status_metrics( - metrics, dir_path, peer, - format_and_order_sync_stat_for_display( - omap_stats[omap_key])) + # Single-dir query: peek complete cache (key: filesystem + # only); on miss fall back to partial cache (key: + # filesystem, dir_path, peer_ids). + complete_metrics = self.sync_stat_complete_cache.cache_peek( + filesystem) + if complete_metrics is not None: + metrics = try_get_from_complete( + complete_metrics, mirrored_dir_path, peer_uuid, + requested_peers) + if metrics is not None: + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'served from complete cache', + filesystem, dir_path, peer_uuid or '*') + return 0, json.dumps({'metrics': metrics}, indent=4), '' + + partial_info_before = self.sync_stat_partial_cache.cache_info() + raw_metrics = self.sync_stat_partial_cache( + filesystem, dir_path, frozenset(requested_peers)) + if (self.sync_stat_partial_cache.cache_info().hits > + partial_info_before.hits): + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'served from partial cache', + filesystem, dir_path, peer_uuid or '*') + else: + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'loaded from omap', + filesystem, dir_path, peer_uuid or '*') + metrics = metrics_for_dir_and_peers( + raw_metrics, dir_path, requested_peers) + return 0, json.dumps({'metrics': metrics}, indent=4), '' + + # Full-scan query: load complete cache on miss (key: filesystem + # only); peer filtering is applied when serving. + complete_info_before = self.sync_stat_complete_cache.cache_info() + complete_metrics = self.sync_stat_complete_cache(filesystem) + complete_hit = (self.sync_stat_complete_cache.cache_info().hits > + complete_info_before.hits) + metrics = try_get_from_complete( + complete_metrics, mirrored_dir_path, peer_uuid, requested_peers) + if complete_hit: + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'served from complete cache', + filesystem, mirrored_dir_path or '*', + peer_uuid or '*') else: - metrics = metrics_load.load_sync_stat_metrics( - ioctx, filesystem, peer_uuid) + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'loaded from omap', + filesystem, mirrored_dir_path or '*', + peer_uuid or '*') return 0, json.dumps({'metrics': metrics}, indent=4), '' except MirrorException as me: return me.args[0], '', me.args[1] From 15e998b865b99b3091054321cf0fdf6df9f327ab Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 22:58:38 +0530 Subject: [PATCH 377/596] mgr/mirroring: detect stale snapshot mirror sync metrics in omap Persisted metrics in the cephfs_mirror omap can outlive the writing daemon when cephfs-mirror stops or a directory is reshuffled to another instance; mgr would keep reporting stale progress until the owning daemon writes again. Extend format_and_order_sync_stat_for_display() to compare persisted _instance_id against InstanceWatcher live instances (via FSPolicy.get_live_instance_ids()) and the directory's tracked instance (via Policy.get_tracked_instance_id()). Mark metrics stale when the persisted writer is no longer live (any state), or when it does not match the tracked instance while persisted state is not "idle". Show state "stale" with current_syncing_snap omitted. Pass policy and live instance ids through load_sync_stat_metrics() and fetch_sync_stat_metrics(), and into sync_stat_complete_cache and sync_stat_partial_cache loaders. Cache hits serve already-formatted (stale-marked) entries until TTL expiry without re-checking instance liveness. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/dir_map/policy.py | 6 ++++ src/pybind/mgr/mirroring/fs/metrics/format.py | 36 +++++++++++++++++-- src/pybind/mgr/mirroring/fs/metrics/load.py | 15 +++++--- .../mgr/mirroring/fs/snapshot_mirror.py | 16 +++++++-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/pybind/mgr/mirroring/fs/dir_map/policy.py b/src/pybind/mgr/mirroring/fs/dir_map/policy.py index aef90b55fe5..da61bec940b 100644 --- a/src/pybind/mgr/mirroring/fs/dir_map/policy.py +++ b/src/pybind/mgr/mirroring/fs/dir_map/policy.py @@ -105,6 +105,12 @@ class Policy: 'purging': dir_state.purging} return None + def get_tracked_instance_id(self, dir_path): + lookup = self.lookup(dir_path) + if not lookup: + return None + return lookup.get('instance_id') + def map(self, dir_path, dir_state): log.debug(f'mapping {dir_path}') min_instance_id = None diff --git a/src/pybind/mgr/mirroring/fs/metrics/format.py b/src/pybind/mgr/mirroring/fs/metrics/format.py index b208151c167..87fc6c4a86a 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/format.py +++ b/src/pybind/mgr/mirroring/fs/metrics/format.py @@ -75,12 +75,44 @@ def format_peer_status_metrics(metrics, dir_path, peer_uuid, stat): metrics.setdefault(dir_path, {}).setdefault('peer', {})[peer_uuid] = stat +def _mark_sync_stat_stale(stat): + out = dict(stat) + out.pop('current_syncing_snap', None) + out['state'] = 'stale' + return out + + +def _apply_stale_sync_metrics(stat, policy=None, dir_path=None, + live_instance_ids=None): + if not isinstance(stat, dict) or policy is None or dir_path is None: + return stat + + persisted_instance_id = stat.get('_instance_id') + if persisted_instance_id is None: + return stat + + persisted_id = str(persisted_instance_id) + if (live_instance_ids is not None and + persisted_id not in live_instance_ids): + return _mark_sync_stat_stale(stat) + + tracked_id = policy.get_tracked_instance_id(dir_path) + if tracked_id is not None: + tracked_id = str(tracked_id) + if (tracked_id != persisted_id and + stat.get('state') != 'idle'): + return _mark_sync_stat_stale(stat) + return stat + + # to match the output of peer_status -def format_and_order_sync_stat_for_display(stat): +def format_and_order_sync_stat_for_display(stat, policy=None, dir_path=None, + live_instance_ids=None): if not isinstance(stat, dict): return stat - out = dict(stat) + out = dict(_apply_stale_sync_metrics( + stat, policy, dir_path, live_instance_ids)) last_synced_snap = out.get('last_synced_snap') if isinstance(last_synced_snap, dict): snap = dict(last_synced_snap) diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py index d157b36b7c2..dc7682fa707 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/load.py +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -57,7 +57,8 @@ def open_metadata_ioctx(rados_inst, fs_map, filesystem): f'failed to open metadata pool for {filesystem}') -def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): +def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None, policy=None, + live_instance_ids=None): metrics: Dict[str, Any] = {} prefix = f'{SYNC_STAT_KEY_PREFIX}/{filesystem}/' if peer_uuid: @@ -89,7 +90,8 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): continue format_peer_status_metrics( metrics, dir_path, peer, - format_and_order_sync_stat_for_display(stat)) + format_and_order_sync_stat_for_display( + stat, policy, dir_path, live_instance_ids)) start = omap_vals.popitem()[0] except rados.Error as e: log.error(f'failed to read sync stat omap: {e}') @@ -97,7 +99,8 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None): return metrics -def fetch_sync_stat_metrics(ioctx, filesystem, peers, mirrored_dir_path, peer_uuid): +def fetch_sync_stat_metrics(ioctx, filesystem, peers, mirrored_dir_path, + peer_uuid, policy=None, live_instance_ids=None): if mirrored_dir_path: dir_path = norm_path(mirrored_dir_path) keys = [sync_stat_omap_key(filesystem, peer, dir_path) for peer in peers] @@ -109,8 +112,10 @@ def fetch_sync_stat_metrics(ioctx, filesystem, peers, mirrored_dir_path, peer_uu if stat is not None: format_peer_status_metrics( metrics, dir_path, peer, - format_and_order_sync_stat_for_display(stat)) + format_and_order_sync_stat_for_display( + stat, policy, dir_path, live_instance_ids)) return metrics, False, dir_path - metrics = load_sync_stat_metrics(ioctx, filesystem, peer_uuid) + metrics = load_sync_stat_metrics( + ioctx, filesystem, peer_uuid, policy, live_instance_ids) return metrics, True, None diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 2b1fa76425e..5c6a1230d31 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -66,6 +66,13 @@ class FSPolicy: def schedule_action(self, dir_paths): self.dir_paths.extend(dir_paths) + def get_live_instance_ids(self): + watcher = self.instance_watcher + if watcher is None: + return None + with watcher.lock: + return frozenset(str(instance_id) for instance_id in watcher.instances) + def init(self, dir_mapping, instances): with self.lock: self.policy.init(dir_mapping) @@ -774,9 +781,12 @@ class FSSnapshotMirror: """ log.debug('sync stat metrics for filesystem %s loaded from omap (complete)', filesystem) + fspolicy = self.pool_policy[filesystem] ioctx = metrics_load.open_metadata_ioctx( self.rados, self.fs_map, filesystem) - return metrics_load.load_sync_stat_metrics(ioctx, filesystem) + return metrics_load.load_sync_stat_metrics( + ioctx, filesystem, None, fspolicy.policy, + fspolicy.get_live_instance_ids()) @lru_cache_timeout( lambda self, *_args, **_kwargs: CACHE_TTL_SECS, @@ -793,10 +803,12 @@ class FSSnapshotMirror: 'loaded from omap', filesystem, dir_path, peer_scope) peers = {peer_id: None for peer_id in peer_ids} + fspolicy = self.pool_policy[filesystem] ioctx = metrics_load.open_metadata_ioctx( self.rados, self.fs_map, filesystem) metrics, _, _ = metrics_load.fetch_sync_stat_metrics( - ioctx, filesystem, peers, dir_path, None) + ioctx, filesystem, peers, dir_path, None, + fspolicy.policy, fspolicy.get_live_instance_ids()) return metrics def metrics_status(self, filesystem, mirrored_dir_path, peer_uuid): From c24b8d3cc2fb36ea77276598a2d1c2f90b1304ec Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 23:05:30 +0530 Subject: [PATCH 378/596] mgr/mirroring: Show default stats on newly added dir_root When the directory is added for mirroring and the snapshot is not taken yet, the peer_status show the following default metrics. { 'state': 'idle', 'snaps_synced': 0, 'snaps_deleted': 0, 'snaps_renamed': 0, } The mgr interface should also match that. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/metrics/format.py | 9 +++++ src/pybind/mgr/mirroring/fs/metrics/load.py | 40 +++++++++++++++---- .../mgr/mirroring/fs/snapshot_mirror.py | 3 +- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/pybind/mgr/mirroring/fs/metrics/format.py b/src/pybind/mgr/mirroring/fs/metrics/format.py index 87fc6c4a86a..943eeb14e2c 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/format.py +++ b/src/pybind/mgr/mirroring/fs/metrics/format.py @@ -1,3 +1,12 @@ +def default_sync_stat_metrics(): + return { + 'state': 'idle', + 'snaps_synced': 0, + 'snaps_deleted': 0, + 'snaps_renamed': 0, + } + + # Matches the format_time function used in peer_status def _format_time(total_seconds_d): total_seconds = int(round(total_seconds_d)) diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py index dc7682fa707..8b2ae3641ae 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/load.py +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -16,7 +16,11 @@ from ..utils import ( parse_sync_stat_omap_key, sync_stat_omap_key, ) -from .format import format_and_order_sync_stat_for_display, format_peer_status_metrics +from .format import ( + default_sync_stat_metrics, + format_and_order_sync_stat_for_display, + format_peer_status_metrics, +) log = logging.getLogger(__name__) @@ -57,8 +61,23 @@ def open_metadata_ioctx(rados_inst, fs_map, filesystem): f'failed to open metadata pool for {filesystem}') +def _fill_missing_sync_stat_defaults(metrics, dir_paths, peers, policy, + live_instance_ids, peer_uuid=None): + default_stat = default_sync_stat_metrics() + for dir_path in dir_paths: + for peer in peers: + if peer_uuid is not None and peer != peer_uuid: + continue + if metrics.get(dir_path, {}).get('peer', {}).get(peer) is not None: + continue + format_peer_status_metrics( + metrics, dir_path, peer, + format_and_order_sync_stat_for_display( + default_stat, policy, dir_path, live_instance_ids)) + + def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None, policy=None, - live_instance_ids=None): + live_instance_ids=None, peers=None): metrics: Dict[str, Any] = {} prefix = f'{SYNC_STAT_KEY_PREFIX}/{filesystem}/' if peer_uuid: @@ -96,6 +115,11 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None, policy=None, except rados.Error as e: log.error(f'failed to read sync stat omap: {e}') raise MirrorException(-e.errno, 'failed to read sync stat omap') + if policy is not None and peers: + with policy.lock: + dir_paths = list(policy.dir_states.keys()) + _fill_missing_sync_stat_defaults( + metrics, dir_paths, peers, policy, live_instance_ids, peer_uuid) return metrics @@ -106,14 +130,14 @@ def fetch_sync_stat_metrics(ioctx, filesystem, peers, mirrored_dir_path, keys = [sync_stat_omap_key(filesystem, peer, dir_path) for peer in peers] omap_stats = load_sync_stat_by_keys(ioctx, keys) metrics: Dict[str, Any] = {} + default_stat = default_sync_stat_metrics() for peer in peers: omap_key = sync_stat_omap_key(filesystem, peer, dir_path) - stat = omap_stats.get(omap_key) - if stat is not None: - format_peer_status_metrics( - metrics, dir_path, peer, - format_and_order_sync_stat_for_display( - stat, policy, dir_path, live_instance_ids)) + stat = omap_stats.get(omap_key, default_stat) + format_peer_status_metrics( + metrics, dir_path, peer, + format_and_order_sync_stat_for_display( + stat, policy, dir_path, live_instance_ids)) return metrics, False, dir_path metrics = load_sync_stat_metrics( diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 5c6a1230d31..ec7e838af52 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -786,7 +786,8 @@ class FSSnapshotMirror: self.rados, self.fs_map, filesystem) return metrics_load.load_sync_stat_metrics( ioctx, filesystem, None, fspolicy.policy, - fspolicy.get_live_instance_ids()) + fspolicy.get_live_instance_ids(), + self.get_filesystem_peers(filesystem)) @lru_cache_timeout( lambda self, *_args, **_kwargs: CACHE_TTL_SECS, From a7325bafb9ab29f17f45e4c14db56a86632d0ff1 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 23:18:15 +0530 Subject: [PATCH 379/596] mgr/mirroring: handle missing cephfs_mirror object in metrics status When snapshot mirroring is not enabled for a filesystem, the cephfs_mirror RADOS object does not exist and "ceph fs snapshot mirror status" fails reading sync stat omap. Map rados ENOENT to a clear MirrorException so the CLI reports that snapshot mirroring must be enabled instead of a generic omap read failure or an uncaught exception. Also catch unexpected errors in metrics_status like other mirror CLI handlers, so the mgr module does not crash on failure. Return -errno.EINVAL from the generic error path instead of the exception message as exit code. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/metrics/load.py | 20 +++++++++++++++---- .../mgr/mirroring/fs/snapshot_mirror.py | 3 +++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/pybind/mgr/mirroring/fs/metrics/load.py b/src/pybind/mgr/mirroring/fs/metrics/load.py index 8b2ae3641ae..3b7a275f5ce 100644 --- a/src/pybind/mgr/mirroring/fs/metrics/load.py +++ b/src/pybind/mgr/mirroring/fs/metrics/load.py @@ -24,6 +24,20 @@ from .format import ( log = logging.getLogger(__name__) +MIRROR_OBJECT_NOT_FOUND_MSG = ( + 'cephfs_mirror object not found; enable snapshot mirroring with ' + '"ceph fs snapshot mirror enable "') + + +def _raise_on_sync_stat_read_error(err): + if isinstance(err, rados.Error) and err.errno == errno.ENOENT: + raise MirrorException(-errno.ENOENT, MIRROR_OBJECT_NOT_FOUND_MSG) + if isinstance(err, rados.Error): + log.error(f'failed to read sync stat omap: {err}') + err_no = err.errno if err.errno is not None else errno.EINVAL + raise MirrorException(-err_no, 'failed to read sync stat omap') + raise err + def load_sync_stat_by_keys(ioctx, keys): stats: Dict[str, Any] = {} @@ -43,8 +57,7 @@ def load_sync_stat_by_keys(ioctx, keys): except (json.JSONDecodeError, UnicodeDecodeError) as e: log.warning(f'failed to decode sync stat for key {key}: {e}') except rados.Error as e: - log.error(f'failed to read sync stat omap keys: {e}') - raise MirrorException(-e.errno, 'failed to read sync stat omap keys') + _raise_on_sync_stat_read_error(e) return stats @@ -113,8 +126,7 @@ def load_sync_stat_metrics(ioctx, filesystem, peer_uuid=None, policy=None, stat, policy, dir_path, live_instance_ids)) start = omap_vals.popitem()[0] except rados.Error as e: - log.error(f'failed to read sync stat omap: {e}') - raise MirrorException(-e.errno, 'failed to read sync stat omap') + _raise_on_sync_stat_read_error(e) if policy is not None and peers: with policy.lock: dir_paths = list(policy.dir_states.keys()) diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index ec7e838af52..dc7f45c32c2 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -900,6 +900,9 @@ class FSSnapshotMirror: return 0, json.dumps({'metrics': metrics}, indent=4), '' except MirrorException as me: return me.args[0], '', me.args[1] + except Exception as e: + log.error(f'failed to get snapshot mirror metrics: {e}') + return -errno.EINVAL, '', 'failed to get snapshot mirror metrics' def daemon_status(self, format='json'): try: From e36faf8bcca5afd6ab4454751fb1d6e84a1bed5f Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 23:20:28 +0530 Subject: [PATCH 380/596] mgr/mirroring: make snapshot mirror metrics cache TTL configurable Add snapshot_mirror_metrics_cache_ttl as a runtime mgr/mirroring module option (default 15 seconds) instead of a hard-coded CACHE_TTL_SECS. Both complete and partial lru_cache_timeout wrappers read the value when caching omap metrics so operators can tune cache freshness without code changes. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- src/pybind/mgr/mirroring/fs/snapshot_mirror.py | 8 +++++--- src/pybind/mgr/mirroring/module.py | 10 +++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index dc7f45c32c2..12279d6e84f 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -22,7 +22,7 @@ from .utils import INSTANCE_ID_PREFIX, MIRROR_OBJECT_NAME, Finisher, \ AsyncOpTracker, get_metadata_pool, norm_path, connect_to_filesystem, \ disconnect_from_filesystem from .metrics.cache import ( - CACHE_TTL_SECS, COMPLETE_CACHE_MAX, lru_cache_timeout, PARTIAL_CACHE_MAX, + COMPLETE_CACHE_MAX, lru_cache_timeout, PARTIAL_CACHE_MAX, metrics_for_dir_and_peers, try_get_from_complete) from .metrics import load as metrics_load from .exception import MirrorException @@ -768,7 +768,8 @@ class FSSnapshotMirror: return me.args[0], '', me.args[1] @lru_cache_timeout( - lambda self, *_args, **_kwargs: CACHE_TTL_SECS, + lambda self, *_args, **_kwargs: self.mgr.get_module_option( + 'snapshot_mirror_metrics_cache_ttl'), COMPLETE_CACHE_MAX) def sync_stat_complete_cache(self, filesystem): """Load all directories and all peers for a filesystem from omap. @@ -790,7 +791,8 @@ class FSSnapshotMirror: self.get_filesystem_peers(filesystem)) @lru_cache_timeout( - lambda self, *_args, **_kwargs: CACHE_TTL_SECS, + lambda self, *_args, **_kwargs: self.mgr.get_module_option( + 'snapshot_mirror_metrics_cache_ttl'), PARTIAL_CACHE_MAX) def sync_stat_partial_cache(self, filesystem, dir_path, peer_ids): """Load sync-stat omap keys for one directory and a peer set. diff --git a/src/pybind/mgr/mirroring/module.py b/src/pybind/mgr/mirroring/module.py index 1321e27f749..37a8cb63747 100644 --- a/src/pybind/mgr/mirroring/module.py +++ b/src/pybind/mgr/mirroring/module.py @@ -8,7 +8,15 @@ from .fs.snapshot_mirror import FSSnapshotMirror class Module(MgrModule): CLICommand = MirroringCLICommand - MODULE_OPTIONS: List[Option] = [] + MODULE_OPTIONS: List[Option] = [ + Option( + 'snapshot_mirror_metrics_cache_ttl', + type='secs', + default=15, + desc='TTL for cached fs snapshot mirror status omap metrics', + runtime=True, + ), + ] NOTIFY_TYPES = [NotifyType.fs_map] def __init__(self, *args, **kwargs): From 01dd9506f2c62a75841184ac9aa75cf72bf57b5b Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 23:22:29 +0530 Subject: [PATCH 381/596] mgr/mirroring: make snapshot mirror metrics cache optional Add snapshot_mirror_metrics_cache_enabled (default true). When disabled, metrics_status reads omap directly and skips complete and partial caches. When enabled, behavior is unchanged. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- .../mgr/mirroring/fs/snapshot_mirror.py | 29 +++++++++++++++++++ src/pybind/mgr/mirroring/module.py | 7 +++++ 2 files changed, 36 insertions(+) diff --git a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py index 12279d6e84f..0d88613e2d6 100644 --- a/src/pybind/mgr/mirroring/fs/snapshot_mirror.py +++ b/src/pybind/mgr/mirroring/fs/snapshot_mirror.py @@ -767,6 +767,9 @@ class FSSnapshotMirror: except MirrorException as me: return me.args[0], '', me.args[1] + def _metrics_cache_enabled(self): + return self.mgr.get_module_option('snapshot_mirror_metrics_cache_enabled') + @lru_cache_timeout( lambda self, *_args, **_kwargs: self.mgr.get_module_option( 'snapshot_mirror_metrics_cache_ttl'), @@ -814,6 +817,22 @@ class FSSnapshotMirror: fspolicy.policy, fspolicy.get_live_instance_ids()) return metrics + def _load_sync_stat_metrics_from_omap(self, filesystem, mirrored_dir_path, + peer_uuid, peers, fspolicy): + ioctx = metrics_load.open_metadata_ioctx( + self.rados, self.fs_map, filesystem) + if mirrored_dir_path: + dir_path = norm_path(mirrored_dir_path) + metrics, _, _ = metrics_load.fetch_sync_stat_metrics( + ioctx, filesystem, peers, mirrored_dir_path, peer_uuid, + fspolicy.policy, fspolicy.get_live_instance_ids()) + return metrics_for_dir_and_peers(metrics, dir_path, peers) + complete_metrics = metrics_load.load_sync_stat_metrics( + ioctx, filesystem, None, fspolicy.policy, + fspolicy.get_live_instance_ids(), peers) + return try_get_from_complete( + complete_metrics, mirrored_dir_path, peer_uuid, peers) + def metrics_status(self, filesystem, mirrored_dir_path, peer_uuid): """Return persisted mirror directory snapshot metrics as JSON. @@ -848,6 +867,16 @@ class FSSnapshotMirror: else: requested_peers = all_peers + if not self._metrics_cache_enabled(): + log.debug('sync stat metrics for filesystem %s (dir=%s, peer=%s) ' + 'cache disabled; loading from omap', + filesystem, mirrored_dir_path or '*', + peer_uuid or '*') + metrics = self._load_sync_stat_metrics_from_omap( + filesystem, mirrored_dir_path, peer_uuid, + requested_peers, fspolicy) + return 0, json.dumps({'metrics': metrics}, indent=4), '' + if mirrored_dir_path: dir_path = norm_path(mirrored_dir_path) # Single-dir query: peek complete cache (key: filesystem diff --git a/src/pybind/mgr/mirroring/module.py b/src/pybind/mgr/mirroring/module.py index 37a8cb63747..3fbe145864e 100644 --- a/src/pybind/mgr/mirroring/module.py +++ b/src/pybind/mgr/mirroring/module.py @@ -9,6 +9,13 @@ from .fs.snapshot_mirror import FSSnapshotMirror class Module(MgrModule): CLICommand = MirroringCLICommand MODULE_OPTIONS: List[Option] = [ + Option( + 'snapshot_mirror_metrics_cache_enabled', + type='bool', + default=True, + desc='Cache fs snapshot mirror status omap metrics', + runtime=True, + ), Option( 'snapshot_mirror_metrics_cache_ttl', type='secs', From 87638c8be49918a8c794a1212f4122f2aa55f1c7 Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Sun, 21 Jun 2026 23:29:43 +0530 Subject: [PATCH 382/596] doc/cephfs: Document fs snapshot mirror status mgr command Describe the mirroring module command that reads persisted omap metrics, including syntax, output layout, stale detection, caching, and comparison with the admin socket peer status interface. Document --peer_uuid as a named argument for peer-only and directory/peer filtering. Also update PendingReleaseNotes Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- PendingReleaseNotes | 7 + doc/cephfs/cephfs-mirroring.rst | 313 +++++++++++++++++++++++++++++++- doc/dev/cephfs-mirroring.rst | 44 ++++- 3 files changed, 355 insertions(+), 9 deletions(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index dc7544745e0..348fa282cc6 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -188,6 +188,13 @@ ``fs mirror peer status`` fields (directory state, current sync, last sync, and snap summary) and are labeled by peer UUID and mirrored directory path. See https://tracker.ceph.com/issues/73457 +* CephFS Mirroring: The mirroring module provides ``ceph fs snapshot mirror status``, + a Ceph CLI command similar to the ``fs mirror peer status`` admin socket interface + for viewing per-directory snapshot sync metrics. The output layout matches + ``fs mirror peer status`` for core sync fields and additionally includes + ``metrics_updated_at`` (time of the last omap write). Optional filters by + mirrored directory path and peer UUID are supported. For more information, + see https://tracker.ceph.com/issues/76686 * RBD: Mirror snapshot creation and trash purge schedules are now automatically staggered when no explicit "start-time" is specified. This reduces scheduling spikes and distributes work more evenly over time. diff --git a/doc/cephfs/cephfs-mirroring.rst b/doc/cephfs/cephfs-mirroring.rst index b10184112b4..36671f0e182 100644 --- a/doc/cephfs/cephfs-mirroring.rst +++ b/doc/cephfs/cephfs-mirroring.rst @@ -229,8 +229,8 @@ subdirectories or ancestor directories is disallowed:: Error EINVAL: /d0/d1/d2/d3 is a subtree of tracked path /d0/d1/d2 The :ref:`Mirroring Status` section contains -information about the commands for checking the directory mapping (to mirror -daemons) and for checking the directory distribution. +information about checking directory synchronization metrics, directory mapping +(to mirror daemons), and directory distribution. .. _cephfs_mirroring_bootstrap_peers: @@ -307,8 +307,300 @@ CephFS mirroring module provides ``mirror daemon status`` interface to check mir An entry per mirror daemon instance is displayed along with information such as configured peers and basic stats. The peer information includes the remote file system name (``fs_name``), -cluster's Monitor addresses (``mon_host``) and cluster FSID (``fsid``). For more detailed -stats, use the admin socket interface as detailed below. +cluster's Monitor addresses (``mon_host``) and cluster FSID (``fsid``). + +.. _cephfs_mirroring_sync_metric_fields: + +Snapshot sync metric fields +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both ``ceph fs snapshot mirror status`` and ``fs mirror peer status`` report the +same core per-directory sync fields. The ``cephfs-mirror`` daemon writes these +fields (and omap metadata described below) to the metadata pool omap (on the +``cephfs_mirror`` RADOS object) so that the mirroring Manager module can read +them and serve ``ceph fs snapshot mirror status`` without requiring access to a +mirror daemon host or admin socket. On daemon restart, only a subset of fields +is loaded back into daemon memory; the rest are rebuilt or reset for the new +session. + +``ceph fs snapshot mirror status`` additionally exposes ``metrics_updated_at``, +the wall-clock time of the last omap write for that directory and peer. This +field is not present in ``fs mirror peer status``, which reads live in-memory +state from the daemon and does not need a separate persist timestamp. In the +mgr command it helps operators judge how fresh omap-backed metrics are, given +the omap persist interval (``cephfs_mirror_tick_interval``) and Manager response +caching (``snapshot_mirror_metrics_cache_enabled`` and +``snapshot_mirror_metrics_cache_ttl``). + +**Per-session counters** + +``snaps_synced``, ``snaps_deleted``, and ``snaps_renamed`` are written to omap +but count activity in the **current daemon session** only. They are not loaded +from omap when a ``cephfs-mirror`` daemon starts or when a directory is reassigned +to another mirror daemon instance. Counters therefore reset to zero in daemon +memory on restart and on directory reshuffle (in multi-daemon deployments). The +admin socket reflects this immediately; omap may still hold the previous session's +values until the new owning daemon writes an updated entry. + +**Omap persistence and restore on restart** + +.. list-table:: + :widths: 30 20 25 25 + :header-rows: 1 + + * - Field + - Written to omap + - Loaded on daemon restart + - Notes + * - ``last_synced_snap`` (and its subfields) + - Yes + - Yes + - Survives daemon restart; reconciled against the remote snap map on load + * - ``current_syncing_snap`` + - Yes (while syncing) + - No + - Rebuilt when synchronization resumes + * - ``snaps_synced``, ``snaps_deleted``, ``snaps_renamed`` + - Yes + - No + - Written to omap but per-session; not loaded on restart (see above) + * - ``state``, ``failure_reason`` + - Yes + - No + - Reflect the last writer's view until omap is updated again + * - ``_instance_id`` + - Yes + - No + - Internal; used by the Manager for stale detection and omitted from CLI output + * - ``metrics_updated_at`` + - Yes + - No + - Wall-clock time of the last omap write; exposed only by + ``ceph fs snapshot mirror status`` (not ``fs mirror peer status``) + +**Omap cleanup** + +When a directory is removed from mirroring, the ``cephfs-mirror`` daemon deletes +the corresponding omap entry from the ``cephfs_mirror`` object. + +.. _cephfs_mirroring_mgr_snapshot_status: + +Directory snapshot sync metrics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The mirroring module provides the ``fs snapshot mirror status`` command to query +per-directory snapshot synchronization metrics for a mirrored file system. This is +the recommended way to inspect mirroring progress from the Ceph CLI without using +the ``cephfs-mirror`` admin socket. + +Unlike ``fs mirror peer status``, which reads in-memory state from a running mirror +daemon on the local host, this command is implemented in the mirroring Manager +module and reads metrics that ``cephfs-mirror`` has written to the metadata pool +omap. That persistence is what makes cluster-wide status available from any node +that can run ``ceph`` commands. While a sync is in progress, the daemon updates +these omap entries periodically (see ``cephfs_mirror_tick_interval``). +The Manager formats the omap data and returns JSON in the same nested structure +as the admin socket ``fs mirror peer status`` command, including +``metrics_updated_at`` when metrics are read from omap. + +**Syntax** + +.. prompt:: bash $ + + ceph fs snapshot mirror status [] [--peer_uuid=] + +```` + File system on the primary cluster for which mirroring is enabled. + +```` (optional) + Absolute path of a directory configured for mirroring (for example ``/d0``). + When omitted, metrics for all mirrored directories (subject to + ``--peer_uuid``) are returned. + +``--peer_uuid=`` (optional) + UUID of a mirror peer (from ``ceph fs snapshot mirror peer_list``). When + omitted, metrics for all configured peers are returned. When both + ```` and ``--peer_uuid`` are specified, only that + directory/peer pair is reported. + +Examples:: + + # All directories and peers + $ ceph fs snapshot mirror status cephfs + + # One mirrored directory, all peers + $ ceph fs snapshot mirror status cephfs /d0 + + # All directories for one peer + $ ceph fs snapshot mirror status cephfs --peer_uuid=a2dc7784-e7a1-4723-b103-03ee8d8768f8 + + # One directory and one peer + $ ceph fs snapshot mirror status cephfs /d0 --peer_uuid=a2dc7784-e7a1-4723-b103-03ee8d8768f8 + +**Output format** + +The command returns a JSON object with a top-level ``metrics`` key. Per-directory +statistics are nested under ``metrics//peer/`` so the +same directory path can be reported for multiple peers without key collisions. + +When mirroring is enabled but no peers are configured, the command succeeds and +returns an empty metrics object:: + + $ ceph fs snapshot mirror status cephfs + { + "metrics": {} + } + +When a directory has been added for mirroring but no snapshot has been synchronized +yet, the Manager reports the same default idle statistics as the admin socket +interface (``state`` is ``idle`` with zero snap counters and no ``last_synced_snap``). +Full-file-system queries include every directory in the mirror policy, using these +defaults for any directory or peer that does not yet have an omap entry. + +A minimal idle example (one directory, one peer):: + + $ ceph fs snapshot mirror status cephfs /d0 + { + "metrics": { + "/d0": { + "peer": { + "a2dc7784-e7a1-4723-b103-03ee8d8768f8": { + "state": "idle", + "snaps_synced": 0, + "snaps_deleted": 0, + "snaps_renamed": 0 + } + } + } + } + } + +After snapshots are synchronized, fields such as ``last_synced_snap``, +``current_syncing_snap``, ``snaps_synced``, ``snaps_deleted``, and ``snaps_renamed`` +match the admin socket output while the owning daemon is actively persisting omap +updates. The mgr command also includes ``metrics_updated_at`` (see above). +Durations and byte counts are formatted for display (for example ``33s``, +``149.94 MiB``). See :ref:`Snapshot sync metric fields` +for persistence and per-session counter behavior. + +**Directory states** + +A directory can be in one of the following states (under each peer): + +- ``idle``: The directory is not currently being synchronized. +- ``syncing``: The directory is currently being synchronized. The + ``current_syncing_snap`` object describes in-progress snapshot sync (see field + tables below). +- ``stale``: Reported by the Manager when persisted omap data is no longer owned + by a live mirror daemon instance (see :ref:`Stale progress detection`). + ``current_syncing_snap`` is omitted. +- ``failed``: The directory has hit the upper limit of consecutive synchronization + failures. A ``failure_reason`` string may be present. + +.. _cephfs_mirroring_stale_metrics: + +**Stale progress detection** + +Each omap entry records an internal ``_instance_id`` (the RADOS client instance +of the ``cephfs-mirror`` daemon that last wrote the entry). The Manager compares +this value against the set of live mirror daemon instances and against the instance +currently assigned to the directory in the mirroring module's directory map: + +- If the persisted ``_instance_id`` is **not** among the live mirror instances, + the entry is reported as ``stale`` (regardless of the persisted ``state``). +- If the directory's tracked instance differs from the persisted ``_instance_id`` + and the persisted ``state`` is not ``idle``, the entry is reported as ``stale`` + (for example after a directory is reshuffled to another daemon mid-sync). + +In both cases ``current_syncing_snap`` is omitted from the output. The ``stale`` +state is reported only by ``ceph fs snapshot mirror status``, which can read omap +even when no mirror daemon is running. The admin socket requires an active +``cephfs-mirror`` daemon; ``ceph --admin-daemon`` commands fail when the mirror +daemon is not running. + +**After a daemon restart** + +Immediately after a ``cephfs-mirror`` restart, omap may still contain metrics +written by the previous session (including a stale ``_instance_id``). The Manager +may report ``stale`` until the new daemon instance writes an updated omap entry. +Per-session counters in omap can likewise reflect the previous session until the +new daemon persists; the admin socket on the restarted daemon already shows zero +for those counters. See :ref:`Snapshot sync metric fields`. + +**Caching** + +To reduce load on the metadata pool, the Manager keeps two short-lived in-memory +caches of formatted metrics returned by ``fs snapshot mirror status``: + +- **Complete cache** — one entry per file system with all mirrored directories + and peers (a full omap snapshot). Used for ``ceph fs snapshot mirror status + `` and for optional ``--peer_uuid`` filtering. +- **Partial cache** — per-directory omap reads keyed by file system, directory + path, and peer set. Single-directory queries (`` ``) + peek the complete cache first; on miss they use the partial cache so a cold + complete cache does not trigger a full omap scan. + +Caching is enabled by default (``snapshot_mirror_metrics_cache_enabled``). When +disabled, each query reads omap directly. When enabled, the default time-to-live +is 15 seconds (``snapshot_mirror_metrics_cache_ttl``) for both caches. Repeated +queries within the TTL may be served without reading omap again. After the cache +expires, the next query reloads metrics from omap. + +**Comparison with the admin socket** + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - ``ceph fs snapshot mirror status`` + - ``fs mirror peer status`` (admin socket) + * - Invoked via the Ceph CLI through the Manager + - Invoked with ``ceph --admin-daemon`` on a mirror daemon host + * - Reads metrics persisted in the metadata pool omap + - Reads in-memory state from the mirror daemon + * - ``last_synced_snap`` survives daemon restart (from omap) + - ``last_synced_snap`` restored from omap on daemon start + * - Per-session counters may lag omap until the owning daemon re-persists + - Per-session counters reset to zero on daemon restart + * - Can report ``stale`` when the omap writer is no longer live + - Requires a running mirror daemon (unavailable when none is active) + * - Includes ``metrics_updated_at`` (time of last omap write) + - Not present; reads live in-memory state only + * - Optional filters by mirrored directory path and peer UUID + - Reports all mirrored directories for the given peer + +For interactive debugging on a specific mirror daemon host, the admin socket +remains useful. For operators and monitoring integrations that prefer a standard +``ceph`` subcommand, use ``fs snapshot mirror status``. + +**Configuration** + +- ``cephfs_mirror_tick_interval`` (``cephfs-mirror`` daemon, + default: ``5`` seconds) — tick interval for periodic mirroring work, + including how often live sync progress is written to omap while a snapshot + is synchronizing. + +- ``snapshot_mirror_metrics_cache_enabled`` (``mgr/mirroring`` module, default: + ``true``) — whether the Manager caches ``fs snapshot mirror status`` responses. + When ``false``, each query reads omap directly:: + + ceph config set mgr mgr/mirroring/snapshot_mirror_metrics_cache_enabled false + +- ``snapshot_mirror_metrics_cache_ttl`` (``mgr/mirroring`` module, default: + ``15`` seconds) — when caching is enabled, how long the Manager caches + formatted ``fs snapshot mirror status`` responses before reading omap again:: + + ceph config set mgr mgr/mirroring/snapshot_mirror_metrics_cache_ttl 15 + +**Errors** + +The command fails with a non-zero exit code and an error message when: + +- ```` does not exist +- Mirroring is not enabled for the file system +- ```` is not an absolute path, is not configured for mirroring, + or is not found in the mirror policy +- ```` is not configured for the file system CephFS mirror daemons provide admin socket commands for querying mirror status. To check available commands for mirror status use:: @@ -463,14 +755,21 @@ Monotonic clock time in seconds (since daemon startup) when the snapshot sync fi printed with sub-second precision and an ``s`` suffix (for example, ``274900.558797s``). This is not a wall-clock or epoch timestamp. -Synchronization stats including ``snaps_synced``, ``snaps_deleted`` and ``snaps_renamed`` are reset -on daemon restart and/or when a directory is reassigned to another mirror daemon (when -multiple mirror daemons are deployed). +``snaps_synced``, ``snaps_deleted``, and ``snaps_renamed`` are +:ref:`per-session counters`: they count +activity in the current ``cephfs-mirror`` daemon session only and are not restored +from omap on restart. They reset to zero on daemon restart and when a directory is +reassigned to another mirror daemon (in multi-daemon deployments). A directory can be in one of the following states: - ``idle``: The directory is currently not being synchronized. - ``syncing``: The directory is currently being synchronized. +- ``stale``: Reported only by ``ceph fs snapshot mirror status`` when persisted + omap data is no longer owned by a live mirror daemon instance (see + :ref:`Stale progress detection`). The admin + socket does not use this state; ``fs mirror peer status`` requires a running + ``cephfs-mirror`` daemon and fails when none is active. - ``failed``: The directory has hit upper limit of consecutive failures. When a directory is currently being synchronized, the mirror daemon marks it as ``syncing`` and diff --git a/doc/dev/cephfs-mirroring.rst b/doc/dev/cephfs-mirroring.rst index 9cba6cce34c..368aafc367f 100644 --- a/doc/dev/cephfs-mirroring.rst +++ b/doc/dev/cephfs-mirroring.rst @@ -326,8 +326,48 @@ For example: ] One entry per mirror-daemon instance is displayed, along with information -including configured peers and basic statistics. For more detailed statistics, -use the admin socket interface as detailed below. +including configured peers and basic statistics. + +**Directory snapshot sync metrics (mgr)** + +The mirroring module implements ``ceph fs snapshot mirror status``. The +``cephfs-mirror`` daemon persists per-directory sync statistics to the +``cephfs_mirror`` object omap in the metadata pool so the Manager can expose +them through the Ceph CLI without an admin socket on a mirror daemon host. The +``metrics_status`` handler reads that omap, applies stale detection and default +idle metrics for newly mirrored directories, and returns JSON in the same nested +``metrics//peer/`` format as ``fs mirror peer status``. When +``snapshot_mirror_metrics_cache_enabled`` is true (default TTL 15 seconds via +``snapshot_mirror_metrics_cache_ttl``), responses are served from a complete +cache (full file system snapshot) and, for single-directory queries, a partial +per-directory cache that avoids full omap scans on complete-cache miss. When +caching is disabled, each query reads omap directly. Omap entries are marked +``stale`` when the persisted ``_instance_id`` is not among live mirror instances +or does not match the directory's tracked instance while persisted state is not +``idle``. + +Each omap value includes metadata fields written by ``cephfs-mirror``: + +- ``_instance_id`` — RADOS client instance of the writer; used for stale + detection and stripped from CLI output. +- ``metrics_updated_at`` — wall-clock time of the last omap write. Exposed in + ``ceph fs snapshot mirror status`` output only; omitted from admin socket + ``fs mirror peer status``, which reads live in-memory state and does not + need a separate persist timestamp. Operators use this field to judge how + fresh omap-backed metrics are, given ``cephfs_mirror_tick_interval`` omap + persist cadence and Manager caching (``snapshot_mirror_metrics_cache_enabled`` + and ``snapshot_mirror_metrics_cache_ttl``). + +Omap entries are removed when a directory is removed from mirroring. All metric +fields are written to omap; on daemon restart only ``last_synced_snap`` metadata +is loaded back. Per-session counters (``snaps_synced``, ``snaps_deleted``, +``snaps_renamed``) are persisted but not loaded and therefore start at zero each +session. + +See :ref:`Directory snapshot sync metrics` +and :ref:`Snapshot sync metric fields` in +:doc:`/cephfs/cephfs-mirroring` for command syntax, examples, and operator +guidance. CephFS mirror daemons provide admin socket commands for querying mirror status. To list the available commands for ``mirror status``, run the following From b80828fbadf285029239725d9cb175812aadf07a Mon Sep 17 00:00:00 2001 From: Kotresh HR Date: Tue, 23 Jun 2026 20:43:18 +0530 Subject: [PATCH 383/596] qa: Add mgr snapshot mirror status tests Add teuthology coverage for `ceph fs snapshot mirror status`: parity with asok peer_status, default idle metrics, stale omap handling, error paths, filter scopes, daemon restart, and cache TTL. Reuse existing peer_dir_status and metrics assertion helpers. Adjust stale test wait for InstanceWatcher timeout and set a short cache TTL in the cache test. Pass peer_uuid via --peer_uuid= in the test helper for peer-only scope queries. Fixes: https://tracker.ceph.com/issues/76686 Signed-off-by: Kotresh HR --- qa/tasks/cephfs/test_mirroring.py | 677 ++++++++++++++++++++++++++++++ 1 file changed, 677 insertions(+) diff --git a/qa/tasks/cephfs/test_mirroring.py b/qa/tasks/cephfs/test_mirroring.py index ad4642c4e7d..0dd1285b178 100644 --- a/qa/tasks/cephfs/test_mirroring.py +++ b/qa/tasks/cephfs/test_mirroring.py @@ -5,6 +5,7 @@ import base64 import errno import logging import random +import signal import time import functools @@ -14,6 +15,7 @@ from collections import deque from tasks.cephfs.cephfs_test_case import CephFSTestCase from teuthology.exceptions import CommandFailedError from teuthology.contextutil import safe_while +from teuthology.orchestra import run log = logging.getLogger(__name__) @@ -70,6 +72,8 @@ class TestMirroring(CephFSTestCase): PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_FS = "cephfs_mirror_mirrored_filesystems" PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_PEER = "cephfs_mirror_peers" PERF_COUNTER_KEY_NAME_CEPHFS_MIRROR_DIRECTORY = "cephfs_mirror_directory" + MGR_METRICS_CACHE_TTL = 3 + MIRROR_TICK_INTERVAL = 1 def setUp(self): super(TestMirroring, self).setUp() @@ -79,6 +83,60 @@ class TestMirroring(CephFSTestCase): self.secondary_fs_id = self.backup_fs.id self.enable_mirroring_module() self.config_set('client.mirror', 'cephfs_mirror_directory_scan_interval', 1) + self.config_set('client.mirror', 'cephfs_mirror_tick_interval', + self.MIRROR_TICK_INTERVAL) + self.config_set('mgr', 'mgr/mirroring/snapshot_mirror_metrics_cache_ttl', + self.MGR_METRICS_CACHE_TTL) + self.enable_mgr_metrics_cache() + + def disable_mgr_metrics_cache(self): + self.config_set('mgr', 'mgr/mirroring/snapshot_mirror_metrics_cache_enabled', + False) + + def enable_mgr_metrics_cache(self): + self.config_set('mgr', 'mgr/mirroring/snapshot_mirror_metrics_cache_enabled', + True) + + def assert_mgr_mirror_status_scopes(self, fs_name, dir_path, peer_uuid, + expected_dirs, asok_res): + full_res = self.mgr_mirror_status(fs_name) + self.assertEqual(set(full_res['metrics'].keys()), set(expected_dirs)) + + peer_res = self.mgr_mirror_status(fs_name, peer_uuid=peer_uuid) + self.assertEqual(set(peer_res['metrics'].keys()), set(expected_dirs)) + for path in expected_dirs: + self.assertEqual( + set(peer_res['metrics'][path]['peer'].keys()), {peer_uuid}) + + dir_res = self.mgr_mirror_status(fs_name, dir_path) + self.assertEqual(set(dir_res['metrics'].keys()), {dir_path}) + + dir_peer_res = self.mgr_mirror_status(fs_name, dir_path, peer_uuid) + self.assertEqual(set(dir_peer_res['metrics'].keys()), {dir_path}) + self.assertEqual( + set(dir_peer_res['metrics'][dir_path]['peer'].keys()), {peer_uuid}) + + for path in expected_dirs: + for res in (full_res, peer_res): + mgr_stat = self.peer_dir_status(res, path, peer_uuid) + asok_stat = self.peer_dir_status(asok_res, path, peer_uuid) + self.assert_mgr_dir_stat_matches_asok(mgr_stat, asok_stat) + + for res in (dir_res, dir_peer_res): + mgr_stat = self.peer_dir_status(res, dir_path, peer_uuid) + asok_stat = self.peer_dir_status(asok_res, dir_path, peer_uuid) + self.assert_mgr_dir_stat_matches_asok(mgr_stat, asok_stat) + + @retry_assert(timeout=120, interval=1) + def check_mgr_snapshot_mirror_status_scopes_match_asok( + self, fs_name, fs_id, dir_path, peer_uuid, expected_dirs, + expected_state=None): + asok_res = self.peer_status(fs_name, fs_id, peer_uuid) + asok_stat = self.peer_dir_status(asok_res, dir_path, peer_uuid) + if expected_state is not None: + self.assertEqual(asok_stat['state'], expected_state) + self.assert_mgr_mirror_status_scopes( + fs_name, dir_path, peer_uuid, expected_dirs, asok_res) def tearDown(self): self.disable_mirroring_module() @@ -362,6 +420,172 @@ class TestMirroring(CephFSTestCase): self.assertTrue( snap['eta'] == 'calculating...' or bool(re.search(r'\d', snap['eta']))) + def mgr_mirror_status(self, fs_name, mirrored_dir_path=None, peer_uuid=None): + args = ["fs", "snapshot", "mirror", "status", fs_name] + if mirrored_dir_path is not None: + args.append(mirrored_dir_path) + if peer_uuid is not None: + args.append(f'--peer_uuid={peer_uuid}') + return json.loads(self.get_ceph_cmd_stdout(*args)) + + def peer_status(self, fs_name, fs_id, peer_uuid): + return self.mirror_daemon_command( + f'peer status for fs: {fs_name}', + 'fs', 'mirror', 'peer', 'status', + f'{fs_name}@{fs_id}', peer_uuid) + + def dir_status_from_mgr(self, fs_name, dir_name, peer_uuid, + mirrored_dir_path=None): + res = self.mgr_mirror_status( + fs_name, mirrored_dir_path or dir_name, peer_uuid) + return self.peer_dir_status(res, dir_name, peer_uuid) + + def dir_status_from_asok(self, fs_name, fs_id, dir_name, peer_uuid): + res = self.peer_status(fs_name, fs_id, peer_uuid) + return self.peer_dir_status(res, dir_name, peer_uuid) + + def assert_default_idle_dir_stat(self, dir_stat): + self.assertEqual(dir_stat['state'], 'idle') + self.assertEqual(dir_stat['snaps_synced'], 0) + self.assertEqual(dir_stat['snaps_deleted'], 0) + self.assertEqual(dir_stat['snaps_renamed'], 0) + self.assertNotIn('last_synced_snap', dir_stat) + + def assert_mgr_dir_stat_matches_asok(self, mgr_stat, asok_stat): + self.assertEqual(mgr_stat['state'], asok_stat['state']) + for key in ('snaps_synced', 'snaps_deleted', 'snaps_renamed'): + self.assertEqual(mgr_stat.get(key), asok_stat.get(key)) + if 'failure_reason' in asok_stat: + self.assertEqual(mgr_stat.get('failure_reason'), asok_stat['failure_reason']) + if 'last_synced_snap' in asok_stat: + self.assertIn('last_synced_snap', mgr_stat) + self.assertEqual(mgr_stat['last_synced_snap']['name'], + asok_stat['last_synced_snap']['name']) + self.assert_last_synced_snap_metrics(mgr_stat['last_synced_snap']) + if 'current_syncing_snap' in asok_stat: + self.assertIn('current_syncing_snap', mgr_stat) + mgr_snap = mgr_stat['current_syncing_snap'] + asok_snap = asok_stat['current_syncing_snap'] + self.assertEqual(mgr_snap['name'], asok_snap['name']) + self.assert_syncing_snap_metrics( + mgr_snap, sync_mode=asok_snap.get('sync-mode')) + + @retry_assert(timeout=120, interval=1) + def check_mgr_dir_stat_matches_asok(self, fs_name, fs_id, dir_name, peer_uuid, + mirrored_dir_path=None): + mgr_stat = self.dir_status_from_mgr( + fs_name, dir_name, peer_uuid, mirrored_dir_path) + asok_stat = self.dir_status_from_asok(fs_name, fs_id, dir_name, peer_uuid) + try: + self.assert_mgr_dir_stat_matches_asok(mgr_stat, asok_stat) + except RETRY_EXCEPTIONS as e: + e.mgr_stat = mgr_stat + e.asok_stat = asok_stat + raise + + def wait_for_mirror_daemon_stop(self, pid): + with safe_while(sleep=1, tries=60, + action='wait for mirror daemon stop') as proceed: + while proceed(): + try: + cur_pid = self.get_mirror_daemon_pid() + except CommandFailedError: + return + if cur_pid != pid: + return + p = self.mount_a.run_shell(['kill', '-0', cur_pid], + check_status=False) + if p.returncode != 0: + return + + def restart_mirror_daemon(self): + # daemon.start() always calls restart(), which skips stop() once proc + # is cleared. Stop the real cephfs-mirror via its pid file first so + # the new instance can take the pidfile lock. + daemons = list(self.ctx.daemons.iter_daemons_of_role('cephfs-mirror')) + self.assertEqual(len(daemons), 1, + 'expected a single cephfs-mirror daemon') + daemon = daemons[0] + rados_inst_before = self.get_mirror_rados_addr( + self.primary_fs_name, self.primary_fs_id) + pid = self.get_mirror_daemon_pid() + + log.debug(f'SIGTERM to cephfs-mirror pid {pid}') + if daemon.running(): + try: + daemon.signal(signal.SIGTERM, silent=True) + except Exception as e: + log.debug(f'failed to signal cephfs-mirror via teuthology: {e}') + self.mount_a.run_shell(['kill', '-TERM', pid]) + self.wait_for_mirror_daemon_stop(pid) + + if daemon.running(): + try: + run.wait([daemon.proc], timeout=10) + except CommandFailedError: + pass + daemon.reset() + + log.debug('starting cephfs-mirror') + daemon.start() + + time.sleep(60) + with safe_while(sleep=2, tries=30, + action='wait for mirror daemon restart') as proceed: + while proceed(): + try: + rados_inst = self.get_mirror_rados_addr( + self.primary_fs_name, self.primary_fs_id) + if rados_inst and rados_inst != rados_inst_before: + break + except CommandFailedError: + pass + + def wait_for_mirror_daemon_recovery(self, fs_name, fs_id, dir_name, peer_uuid): + # A new rados_inst alone does not mean mirroring is ready: wait until the + # peer is configured, the snap dir is registered, and asok reports it. + with safe_while(sleep=2, tries=60, + action='wait for mirror daemon recovery') as proceed: + while proceed(): + if not self.get_mirror_rados_addr(fs_name, fs_id): + continue + try: + mirror_res = self.mirror_daemon_command( + f'mirror status for fs: {fs_name}', + 'fs', 'mirror', 'status', f'{fs_name}@{fs_id}') + except CommandFailedError: + continue + if peer_uuid not in mirror_res.get('peers', {}): + continue + if mirror_res.get('snap_dirs', {}).get('dir_count', 0) < 1: + continue + try: + peer_res = self.peer_status(fs_name, fs_id, peer_uuid) + self.peer_dir_status(peer_res, dir_name, peer_uuid) + except RETRY_EXCEPTIONS: + continue + return + + @retry_assert(timeout=120, interval=1) + def check_mgr_and_asok_session_counters_zero(self, fs_name, fs_id, dir_name, + peer_uuid): + mgr_stat = self.dir_status_from_mgr(fs_name, dir_name, peer_uuid) + asok_stat = self.dir_status_from_asok(fs_name, fs_id, dir_name, peer_uuid) + self.assertEqual(mgr_stat['state'], 'idle') + self.assertEqual(asok_stat['state'], 'idle') + for key in ('snaps_synced', 'snaps_deleted', 'snaps_renamed'): + self.assertEqual(mgr_stat.get(key), 0, msg=f'mgr {key}') + self.assertEqual(asok_stat.get(key), 0, msg=f'asok {key}') + self.assertEqual(mgr_stat['last_synced_snap']['name'], + asok_stat['last_synced_snap']['name']) + + @retry_assert(timeout=90, interval=5) + def check_mgr_dir_stat_stale(self, fs_name, dir_name, peer_uuid): + mgr_stat = self.dir_status_from_mgr(fs_name, dir_name, peer_uuid) + self.assertEqual(mgr_stat['state'], 'stale', + msg=f'unexpected mgr stat: {mgr_stat}') + self.assertNotIn('current_syncing_snap', mgr_stat) + @retry_assert(timeout=120, interval=1) def check_peer_syncing_progress_metrics(self, fs_name, fs_id, peer_spec, dir_name, snap_name, sync_mode=None): @@ -2175,6 +2399,459 @@ class TestMirroring(CephFSTestCase): self.config_set('client.mirror', 'cephfs_mirror_distribute_datasync_threads', 'true') self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + def test_mgr_snapshot_mirror_status_matches_asok_idle(self): + """Mgr snapshot mirror status matches asok peer status after sync.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_status_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap_name, 1) + + peer_uuid = self.get_peer_uuid(peer_spec) + self.check_mgr_dir_stat_matches_asok( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_uuid) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_matches_asok_syncing(self): + """Mgr snapshot mirror status matches asok peer status during sync.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_sync_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 8000, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.check_peer_syncing_progress_metrics( + self.primary_fs_name, self.primary_fs_id, peer_spec, f'/{dir_name}', + snap_name, sync_mode='full') + + peer_uuid = self.get_peer_uuid(peer_spec) + self.check_mgr_dir_stat_matches_asok( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_uuid) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_default_idle_new_dir(self): + """Mgr status reports default idle metrics for a newly added directory.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_default_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + peer_uuid = self.get_peer_uuid(peer_spec) + mgr_stat = self.dir_status_from_mgr( + self.primary_fs_name, f'/{dir_name}', peer_uuid) + self.assert_default_idle_dir_stat(mgr_stat) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_stale_after_daemon_stop(self): + """Mgr status marks frozen omap syncing progress as stale.""" + self.setup_mount_b(mds_perm='rw') + self.mount_a.run_shell(["mkdir", "d0"]) + for i in range(8): + self.mount_a.write_n_mb(os.path.join('d0', f'file.{i}'), 1024) + + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + self.add_directory(self.primary_fs_name, self.primary_fs_id, '/d0') + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + self.mount_a.run_shell(["mkdir", "d0/.snap/snap0"]) + self.check_peer_snap_in_progress(self.primary_fs_name, self.primary_fs_id, + peer_spec, '/d0', 'snap0') + + peer_uuid = self.get_peer_uuid(peer_spec) + # Wait for live metrics to reach omap before freezing the daemon. + # Stale detection requires a persisted _instance_id; without an omap + # write the mgr reports default idle metrics instead of stale. + mgr_syncing = False + with safe_while(sleep=2, tries=30, + action='wait for omap syncing metrics') as proceed: + while proceed(): + mgr_stat = self.dir_status_from_mgr( + self.primary_fs_name, '/d0', peer_uuid) + if mgr_stat.get('state') == 'syncing': + mgr_syncing = True + break + self.assertTrue( + mgr_syncing, + 'mgr never reported syncing before SIGSTOP; ' + f'last mgr stat: {mgr_stat}') + + pid = self.get_mirror_daemon_pid() + log.debug(f'SIGSTOP to cephfs-mirror pid {pid}') + self.mount_a.run_shell(['kill', '-SIGSTOP', pid]) + try: + # InstanceWatcher INSTANCE_TIMEOUT is 30s; allow extra time for + # the mgr notify loop to age out the frozen instance. + time.sleep(40) + self.check_mgr_dir_stat_stale( + self.primary_fs_name, '/d0', peer_uuid) + finally: + log.debug('SIGCONT to cephfs-mirror') + self.mount_a.run_shell(['kill', '-SIGCONT', pid]) + + # wait for restart mirror on blocklist + time.sleep(60) + with safe_while(sleep=2, tries=20, + action='wait for mirror daemon recovery after SIGSTOP') as proceed: + while proceed(): + if not self.get_mirror_rados_addr(self.primary_fs_name, + self.primary_fs_id): + continue + res = self.mirror_daemon_command( + f'mirror status for fs: {self.primary_fs_name}', + 'fs', 'mirror', 'status', + f'{self.primary_fs_name}@{self.primary_fs_id}') + if 'snap_dirs' in res: + break + + self.remove_directory(self.primary_fs_name, self.primary_fs_id, '/d0') + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_no_peers(self): + """Mgr status returns empty metrics when no peers are configured.""" + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + res = self.mgr_mirror_status(self.primary_fs_name) + self.assertEqual(res, {'metrics': {}}) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_errors(self): + """Mgr status returns expected errors for invalid inputs.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_err_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + try: + self.get_ceph_cmd_stdout("fs", "snapshot", "mirror", "status", + "nonexistent_fs_name") + except CommandFailedError as ce: + if ce.exitstatus != errno.ENOENT: + raise RuntimeError(-errno.ENOENT, + 'incorrect error for unknown filesystem') + else: + raise RuntimeError(-errno.ENOENT, 'expected unknown filesystem to fail') + + self.remove_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + self.peer_remove(self.primary_fs_name, self.primary_fs_id, peer_spec) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + try: + self.get_ceph_cmd_stdout("fs", "snapshot", "mirror", "status", + self.primary_fs_name) + except CommandFailedError as ce: + if ce.exitstatus != errno.EINVAL: + raise RuntimeError(-errno.EINVAL, + 'incorrect error for non-mirrored filesystem') + else: + raise RuntimeError(-errno.EINVAL, 'expected non-mirrored fs to fail') + + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + try: + self.get_ceph_cmd_stdout("fs", "snapshot", "mirror", "status", + self.primary_fs_name, '/not_mirrored') + except CommandFailedError as ce: + if ce.exitstatus != errno.ENOENT: + raise RuntimeError(-errno.ENOENT, + 'incorrect error for unknown directory') + else: + raise RuntimeError(-errno.ENOENT, 'expected unknown directory to fail') + + try: + self.get_ceph_cmd_stdout( + "fs", "snapshot", "mirror", "status", + self.primary_fs_name, f'/{dir_name}', + '--peer_uuid=00000000-0000-0000-0000-000000000000') + except CommandFailedError as ce: + if ce.exitstatus != errno.ENOENT: + raise RuntimeError(-errno.ENOENT, + 'incorrect error for unknown peer') + else: + raise RuntimeError(-errno.ENOENT, 'expected unknown peer to fail') + + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_filter_scopes(self): + """Mgr status filters by filesystem, directory, and peer scope.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + for dir_name in ('mgr_f0', 'mgr_f1'): + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, + f'/{dir_name}') + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/snap0']) + + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, '/mgr_f0', 'snap0', 1) + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, '/mgr_f1', 'snap0', 1) + + peer_uuid = self.get_peer_uuid(peer_spec) + full_res = self.mgr_mirror_status(self.primary_fs_name) + self.assertIn('/mgr_f0', full_res['metrics']) + self.assertIn('/mgr_f1', full_res['metrics']) + + dir_res = self.mgr_mirror_status(self.primary_fs_name, '/mgr_f0') + self.assertEqual(set(dir_res['metrics'].keys()), {'/mgr_f0'}) + + peer_res = self.mgr_mirror_status( + self.primary_fs_name, '/mgr_f0', peer_uuid) + self.assertEqual(set(peer_res['metrics'].keys()), {'/mgr_f0'}) + self.assertIn(peer_uuid, peer_res['metrics']['/mgr_f0']['peer']) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_survives_daemon_restart(self): + """Mgr status keeps persisted last_synced_snap and resets session counters after restart.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_restart_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 3000, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap0 = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap0}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap0, 1) + + for i in range(5): + self.mount_a.write_n_mb(os.path.join(dir_name, f'more_file.{i}'), 1) + + snap1 = 'snap1' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap1}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap1, 2) + + self.mount_a.run_shell(['rmdir', f'{dir_name}/.snap/{snap0}']) + self.check_peer_status_deleted_snap(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', 1) + snap_list = self.mount_b.ls(path=f'{dir_name}/.snap') + self.assertNotIn(snap0, snap_list) + + snap2 = 'snap2' + self.mount_a.run_shell(['mv', f'{dir_name}/.snap/{snap1}', + f'{dir_name}/.snap/{snap2}']) + self.check_peer_status_renamed_snap(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', 1) + snap_list = self.mount_b.ls(path=f'{dir_name}/.snap') + self.assertNotIn(snap1, snap_list) + self.assertIn(snap2, snap_list) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap2, 2) + + peer_uuid = self.get_peer_uuid(peer_spec) + before = self.dir_status_from_mgr( + self.primary_fs_name, f'/{dir_name}', peer_uuid) + self.assertEqual(before['snaps_synced'], 2) + self.assertEqual(before['snaps_deleted'], 1) + self.assertEqual(before['snaps_renamed'], 1) + self.assertEqual(before['last_synced_snap']['name'], snap2) + + self.restart_mirror_daemon() + self.wait_for_mirror_daemon_recovery( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_uuid) + self.check_mgr_and_asok_session_counters_zero( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_uuid) + + after = self.dir_status_from_mgr( + self.primary_fs_name, f'/{dir_name}', peer_uuid) + self.assertEqual(after['last_synced_snap']['name'], + before['last_synced_snap']['name']) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_after_directory_remove(self): + """Mgr status for a removed directory returns ENOENT.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_rm_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + self.remove_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + try: + self.mgr_mirror_status(self.primary_fs_name, f'/{dir_name}') + except CommandFailedError as ce: + if ce.exitstatus != errno.ENOENT: + raise RuntimeError(-errno.ENOENT, + 'incorrect error for removed directory') + else: + raise RuntimeError(-errno.ENOENT, 'expected removed directory to fail') + + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_failed_state(self): + """Mgr status reports failed state like asok peer status.""" + self.setup_mount_b(mds_perm='rwps') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'd0' + self.mount_a.run_shell(['mkdir', dir_name]) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap_name = "snap_a" + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap_name}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap_name, 1) + + remote_snap_path = f'{dir_name}/.snap/snap_b' + self.mount_b.run_shell(['sudo', 'mkdir', remote_snap_path], omit_sudo=False) + + self.verify_failed_directory(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}') + peer_uuid = self.get_peer_uuid(peer_spec) + self.check_mgr_dir_stat_matches_asok( + self.primary_fs_name, self.primary_fs_id, f'/{dir_name}', peer_uuid) + + self.mount_b.run_shell(['sudo', 'rmdir', remote_snap_path], omit_sudo=False) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_cache(self): + """Repeated mgr status calls hit the metrics cache within TTL.""" + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dir_name = 'mgr_cache_dir' + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, f'/{dir_name}') + + snap0 = 'snap0' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap0}']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap0, 1) + + res1 = self.mgr_mirror_status(self.primary_fs_name) + res2 = self.mgr_mirror_status(self.primary_fs_name) + self.assertEqual(res1, res2) + + snap1 = 'snap1' + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/{snap1}']) + self.check_peer_status(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{dir_name}', snap1, 2) + + time.sleep(self.MGR_METRICS_CACHE_TTL + 1) + res3 = self.mgr_mirror_status(self.primary_fs_name) + peer_uuid = self.get_peer_uuid(peer_spec) + self.assertEqual( + res3['metrics'][f'/{dir_name}']['peer'][peer_uuid]['snaps_synced'], 2) + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + + def test_mgr_snapshot_mirror_status_cache_disabled(self): + """Mgr snapshot mirror status reads omap for all scopes when cache is disabled.""" + self.disable_mgr_metrics_cache() + try: + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + dirs = [] + for dir_name in ('mgr_nc0', 'mgr_nc1'): + self.mount_a.run_shell(['mkdir', dir_name]) + self.mount_a.create_n_files(f'{dir_name}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, + f'/{dir_name}') + self.mount_a.run_shell(['mkdir', f'{dir_name}/.snap/snap0']) + dirs.append(f'/{dir_name}') + + peer_uuid = self.get_peer_uuid(peer_spec) + for dir_path in dirs: + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, dir_path, 'snap0', 1) + + self.check_mgr_snapshot_mirror_status_scopes_match_asok( + self.primary_fs_name, self.primary_fs_id, dirs[0], peer_uuid, + dirs) + + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + finally: + self.enable_mgr_metrics_cache() + + def test_mgr_snapshot_mirror_status_cache_disabled_syncing(self): + """Mgr snapshot mirror status reads omap during sync when cache is disabled.""" + self.disable_mgr_metrics_cache() + try: + self.setup_mount_b(mds_perm='rw') + self.enable_mirroring(self.primary_fs_name, self.primary_fs_id) + peer_spec = "client.mirror_remote@ceph" + self.peer_add(self.primary_fs_name, self.primary_fs_id, peer_spec, + self.secondary_fs_name) + + idle_dir = 'mgr_nc_idle' + self.mount_a.run_shell(['mkdir', idle_dir]) + self.mount_a.create_n_files(f'{idle_dir}/file', 50, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, + f'/{idle_dir}') + self.mount_a.run_shell(['mkdir', f'{idle_dir}/.snap/snap0']) + self.check_peer_status_idle(self.primary_fs_name, self.primary_fs_id, + peer_spec, f'/{idle_dir}', 'snap0', 1) + + sync_dir = 'mgr_nc_sync' + self.mount_a.run_shell(['mkdir', sync_dir]) + self.mount_a.create_n_files(f'{sync_dir}/file', 10000, sync=True) + self.add_directory(self.primary_fs_name, self.primary_fs_id, + f'/{sync_dir}') + snap_name = 'snap0' + self.mount_a.run_shell(['mkdir', f'{sync_dir}/.snap/{snap_name}']) + self.check_peer_syncing_progress_metrics( + self.primary_fs_name, self.primary_fs_id, peer_spec, + f'/{sync_dir}', snap_name, sync_mode='full') + + peer_uuid = self.get_peer_uuid(peer_spec) + dirs = [f'/{idle_dir}', f'/{sync_dir}'] + self.check_mgr_snapshot_mirror_status_scopes_match_asok( + self.primary_fs_name, self.primary_fs_id, f'/{sync_dir}', + peer_uuid, dirs, expected_state='syncing') + self.disable_mirroring(self.primary_fs_name, self.primary_fs_id) + finally: + self.enable_mgr_metrics_cache() + def test_cephfs_mirror_duplicate_acquire_notify(self): """Duplicate acquire notifies must not leave a ghost replayer directory. From 212eb0bc04db50f38f468ec7bcff149a1ca745b4 Mon Sep 17 00:00:00 2001 From: David Galloway Date: Tue, 23 Jun 2026 10:11:34 -0400 Subject: [PATCH 384/596] .github/workflows/releng-audit.yaml: Check main for upstream commit This check was failing to correctly identify whether backported commits had actually been cherry-picked because it was checking the target branch instead of the main branch. This change checks out the main branch to look for the upstream cherry-picked commit. Signed-off-by: David Galloway --- .github/workflows/releng-audit.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/releng-audit.yaml b/.github/workflows/releng-audit.yaml index 4a4ab014986..a574d7e076d 100644 --- a/.github/workflows/releng-audit.yaml +++ b/.github/workflows/releng-audit.yaml @@ -258,7 +258,14 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - + + - name: Fetch main for parity check + if: steps.router.outputs.run_audit == 'true' + # checkout only fetches the PR's base branch (e.g. squid/tentacle/umbrella). + # The parity check needs the main ref to walk the commit history graph and find the upstream + # merge commit for each cherry-pick, so fetch it explicitly as origin/main. + run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + - name: Setup Python if: steps.router.outputs.run_audit == 'true' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 From 2c9b73894b4525fb2fcb7abf33da4250fde92fe8 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 16:14:19 +0800 Subject: [PATCH 385/596] cmake: factor the ASan/LSan test options into cache variables add_ceph_test() spelled out the suppression-file paths and sanitizer flags inline. bin/ceph needs the same options, so lift them into CEPH_ASAN_OPTIONS and CEPH_LSAN_OPTIONS and have add_ceph_test() consume those. The environment the tests run with is unchanged. Signed-off-by: Kefu Chai --- CMakeLists.txt | 7 +++++++ cmake/modules/AddCephTest.cmake | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f2abb516b65..0fcdf9664bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -724,6 +724,13 @@ endif(LINUX) option(WITH_ASAN "build with ASAN" OFF) if(WITH_ASAN) list(APPEND sanitizers "address") + # Shared ASan/LSan runtime options: the in-tree suppression files plus the + # flags our tests need. Consumed by add_ceph_test() and baked into bin/ceph + # so both ignore the same still-reachable third-party leaks. + set(CEPH_ASAN_OPTIONS "suppressions=${CMAKE_SOURCE_DIR}/qa/asan.supp,detect_odr_violation=0" + CACHE INTERNAL "ASAN_OPTIONS for ceph tests and the ceph CLI") + set(CEPH_LSAN_OPTIONS "suppressions=${CMAKE_SOURCE_DIR}/qa/lsan.supp,print_suppressions=0" + CACHE INTERNAL "LSAN_OPTIONS for ceph tests and the ceph CLI") endif() option(WITH_ASAN_LEAK "explicitly enable ASAN leak detection" OFF) diff --git a/cmake/modules/AddCephTest.cmake b/cmake/modules/AddCephTest.cmake index 49562e569b0..a96ea2231af 100644 --- a/cmake/modules/AddCephTest.cmake +++ b/cmake/modules/AddCephTest.cmake @@ -33,8 +33,8 @@ function(add_ceph_test test_name test_path) set_property(TEST ${test_name} APPEND PROPERTY ENVIRONMENT - ASAN_OPTIONS=suppressions=${CMAKE_SOURCE_DIR}/qa/asan.supp,detect_odr_violation=0 - LSAN_OPTIONS=suppressions=${CMAKE_SOURCE_DIR}/qa/lsan.supp,print_suppressions=0) + ASAN_OPTIONS=${CEPH_ASAN_OPTIONS} + LSAN_OPTIONS=${CEPH_LSAN_OPTIONS}) endif() set_property(TEST ${test_name} PROPERTY TIMEOUT ${CEPH_TEST_TIMEOUT}) From 55ac10180cd3a1bb202c8132c4caca30404bb6f8 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 16:14:19 +0800 Subject: [PATCH 386/596] ceph.in: load asan/lsan suppressions on WITH_ASAN builds bin/ceph from a WITH_ASAN build aborts at exit, with LeakSanitizer reporting CPython and Cython module-init allocations as leaks: ==2577940==ERROR: LeakSanitizer: detected memory leaks Direct leak ... in PyObject_Malloc (/usr/bin/python3.12+...) #4 __pyx_pymod_exec_rados rados_processed.c SUMMARY: AddressSanitizer: 32113 byte(s) leaked in 30 allocation(s). These are interpreter globals that live for the process lifetime. qa/lsan.supp already suppresses them, but bin/ceph never loaded it: vstart.sh sets LSAN_OPTIONS for the daemons it spawns, while a bin/ceph invoked separately (ceph-api runs ./bin/ceph fsid once vstart.sh returns) inherits none and exits non-zero. It stayed hidden until radosgw-admin stopped crashing in vstart and the run reached that call. ceph.in already re-execs with the ASan runtime preloaded under WITH_ASAN. Set ASAN_OPTIONS and LSAN_OPTIONS first, from the CEPH_ASAN_OPTIONS and CEPH_LSAN_OPTIONS that CMake also feeds add_ceph_test(), so the re-exec'd interpreter starts with the suppressions loaded. Use setdefault so a value from the caller still wins. Signed-off-by: Kefu Chai --- src/ceph.in | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ceph.in b/src/ceph.in index 4694da29db5..069888422c0 100755 --- a/src/ceph.in +++ b/src/ceph.in @@ -140,6 +140,13 @@ if os.path.exists(os.path.join(MYPDIR, "CMakeCache.txt")) \ pythonlib_path = os.path.join(lib_path, "cython_modules", get_pythonlib_dir()) + if with_asan: + # respawn_in_path() below re-execs with the ASan runtime preloaded. + # Load the same suppression options add_ceph_test() uses (configured + # by CMake) first, so the re-exec'd interpreter does not report + # CPython/Cython module-init allocations as leaks at exit. + os.environ.setdefault("ASAN_OPTIONS", "@CEPH_ASAN_OPTIONS@") + os.environ.setdefault("LSAN_OPTIONS", "@CEPH_LSAN_OPTIONS@") respawn_in_path(lib_path, pybind_path, pythonlib_path, asan_lib_path if with_asan else None) From 320a3a0529c7154918321c02e85d57674fb9bfce Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 16:14:19 +0800 Subject: [PATCH 387/596] qa/lsan.supp: suppress CPython 3.12/3.13 interpreter leaks The python binaries on some CI images and dev boxes ship stripped, so the allocator frames in an interpreter-shutdown leak come through unsymbolised (/usr/bin/python3.13+0x...) and the function-name matches above cannot apply. leak:python3.10 already handled this for 3.10, and a stale comment claimed 3.12 does not leak. Add leak:python3.12 and leak:python3.13, mirroring the 3.10 entry, so the interpreter globals are suppressed whatever CPython the build uses. Signed-off-by: Kefu Chai --- qa/lsan.supp | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/qa/lsan.supp b/qa/lsan.supp index e1a20fdccd8..df0897fc96e 100644 --- a/qa/lsan.supp +++ b/qa/lsan.supp @@ -29,15 +29,14 @@ leak:^PyUnicode_New leak:^PyMem_Malloc leak:^PyDict_Copy leak:^_PyObject_GC_NewVar -# Catch unsymbolised interpreter frames in both the standalone -# /usr/bin/python3.10 binary (used by the bin/ceph wrapper) and the -# /lib/.../libpython3.10.so.1.0 embedded by C++ unittests such as -# unittest_mgr_pyformatter / unittest_mgr_pyutil. Not all CPython -# 3.10 internal helpers are exported in .dynsym, so function-name -# matches above don't cover everything; this substring-matches the -# module path in the stack frame. Remove when CI moves to >= 3.12. +# Catch unsymbolised interpreter frames in the standalone python binary used by +# the bin/ceph wrapper and in the libpython embedded by C++ unittests such as +# unittest_mgr_pyformatter / unittest_mgr_pyutil. Not all CPython internal +# helpers are exported in .dynsym, so the function-name matches above don't +# cover everything; this substring-matches the module path in the stack frame. leak:python3.10 -# python3.12 doesn't leak anything +leak:python3.12 +leak:python3.13 # python3.13 leak:^PyModule_ExecDef From f45a1bfc93596f17370ee38ce241ae8123d81e8e Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Tue, 23 Jun 2026 19:00:22 +0530 Subject: [PATCH 388/596] mgr/dashboard: fix bind address regression from CherryPy isolation The CherryPy isolation refactor (PR #67227) accidentally changed the dashboard bind address from wildcard (*:8443) to mon_ip:8443. The get_mgr_ip() replacement was originally only for URI generation, but the refactor passed the mutated address to CherryPyMgr.mount() as the actual socket bind address. This breaks the management gateway when its VIP is not on the same interface as mon_ip, as the dashboard becomes unreachable on other interfaces. Preserve the original wildcard address for binding and only use get_mgr_ip() for the advertised URI. Add regression test to prevent future confusion between bind_addr and server_addr. Fixes: https://tracker.ceph.com/issues/77491 Signed-off-by: Afreen Misbah --- src/pybind/mgr/dashboard/module.py | 3 +- .../mgr/dashboard/tests/test_settings.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index fffa768575b..7b7bb3b77de 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -209,6 +209,7 @@ class CherryPyConfig(object): self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore 'url_prefix', default='')) + bind_addr = server_addr if server_addr in ['::', '0.0.0.0']: server_addr = self.get_mgr_ip() # type: ignore base_url = build_url( @@ -217,7 +218,7 @@ class CherryPyConfig(object): port=server_port, ) uri = f'{base_url}{self.url_prefix}/' - return uri, (server_addr, server_port), ssl_info, config + return uri, (bind_addr, server_port), ssl_info, config def await_configuration(self): """ diff --git a/src/pybind/mgr/dashboard/tests/test_settings.py b/src/pybind/mgr/dashboard/tests/test_settings.py index d6c94b6b0b8..17dad550400 100644 --- a/src/pybind/mgr/dashboard/tests/test_settings.py +++ b/src/pybind/mgr/dashboard/tests/test_settings.py @@ -2,11 +2,13 @@ import errno import unittest +from unittest.mock import MagicMock from mgr_module import ERROR_MSG_EMPTY_INPUT_FILE from .. import settings from ..controllers.settings import Settings as SettingsController +from ..module import CherryPyConfig from ..settings import Settings, handle_option_command from ..tests import ControllerTestCase, KVStoreMockMixin @@ -125,6 +127,33 @@ class SettingsTest(unittest.TestCase, KVStoreMockMixin): self.assertEqual(str(ctx.exception), "type object 'Options' has no attribute 'NON_EXISTENT_OPTION'") + def _setup_cherrypy_config(self, server_addr, server_port): + obj = CherryPyConfig() + config_map = { + 'server_addr': server_addr, + 'ssl': False, + 'server_port': server_port + } + obj.get_localized_module_option = MagicMock( + side_effect=lambda key, default=None: config_map.get(key, default)) + obj.get_mgr_ip = MagicMock(return_value='192.168.1.10') + obj.get_module_option = MagicMock(return_value='') + obj.get_mgr_id = MagicMock(return_value='test') + obj.module_name = 'dashboard' + obj.log = MagicMock() + obj.update_cherrypy_config = MagicMock() + return obj + + def test_wildcard_bind_addr_preserved(self): + obj = self._setup_cherrypy_config('::', 8080) + _, bind_addr, _, _ = obj._configure() # pylint: disable=protected-access + self.assertEqual(bind_addr, ('::', 8080)) + + def test_wildcard_bind_addr_preserved_ipv4(self): + obj = self._setup_cherrypy_config('0.0.0.0', 8443) + _, bind_addr, _, _ = obj._configure() # pylint: disable=protected-access + self.assertEqual(bind_addr, ('0.0.0.0', 8443)) + class SettingsControllerTest(ControllerTestCase, KVStoreMockMixin): @classmethod From cf695e6c81c3793056f08d6d7dd955d209e8e22b Mon Sep 17 00:00:00 2001 From: Laura Flores Date: Fri, 10 Apr 2026 16:41:47 -0500 Subject: [PATCH 389/596] qa/suites/upgrade: add POOL_FULL variations to ignorelist Fixes: https://tracker.ceph.com/issues/75414 Signed-off-by: Laura Flores --- qa/suites/upgrade/squid-x/parallel/0-start.yaml | 3 +++ qa/suites/upgrade/squid-x/stress-split/1-start.yaml | 3 +++ qa/suites/upgrade/telemetry-upgrade/squid-x/1-tasks.yaml | 3 +++ qa/suites/upgrade/telemetry-upgrade/tentacle-x/1-tasks.yaml | 3 +++ qa/suites/upgrade/tentacle-x/parallel/0-start.yaml | 3 +++ qa/suites/upgrade/tentacle-x/stress-split/1-start.yaml | 3 +++ 6 files changed, 18 insertions(+) diff --git a/qa/suites/upgrade/squid-x/parallel/0-start.yaml b/qa/suites/upgrade/squid-x/parallel/0-start.yaml index 111f6989b6b..108db8213b3 100644 --- a/qa/suites/upgrade/squid-x/parallel/0-start.yaml +++ b/qa/suites/upgrade/squid-x/parallel/0-start.yaml @@ -58,3 +58,6 @@ overrides: - OBJECT_UNFOUND - pg .* is stuck peering - OSD_HOST_DOWN + - POOL_FULL + - pool full + - pool\(s\) full diff --git a/qa/suites/upgrade/squid-x/stress-split/1-start.yaml b/qa/suites/upgrade/squid-x/stress-split/1-start.yaml index 59a29f39b0e..f777f007a06 100644 --- a/qa/suites/upgrade/squid-x/stress-split/1-start.yaml +++ b/qa/suites/upgrade/squid-x/stress-split/1-start.yaml @@ -36,6 +36,9 @@ overrides: - CEPHADM_FAILED_DAEMON - failed cephadm daemon - osd.* is in unknown state + - POOL_FULL + - pool full + - pool\(s\) full tasks: - install: branch: squid diff --git a/qa/suites/upgrade/telemetry-upgrade/squid-x/1-tasks.yaml b/qa/suites/upgrade/telemetry-upgrade/squid-x/1-tasks.yaml index e210c12e3d9..0033e9765da 100644 --- a/qa/suites/upgrade/telemetry-upgrade/squid-x/1-tasks.yaml +++ b/qa/suites/upgrade/telemetry-upgrade/squid-x/1-tasks.yaml @@ -26,6 +26,9 @@ overrides: - osd down - OBJECT_UNFOUND - OSD_HOST_DOWN + - POOL_FULL + - pool full + - pool\(s\) full tasks: - install: branch: squid diff --git a/qa/suites/upgrade/telemetry-upgrade/tentacle-x/1-tasks.yaml b/qa/suites/upgrade/telemetry-upgrade/tentacle-x/1-tasks.yaml index 65c0d5aa3c8..9ce22e5df08 100644 --- a/qa/suites/upgrade/telemetry-upgrade/tentacle-x/1-tasks.yaml +++ b/qa/suites/upgrade/telemetry-upgrade/tentacle-x/1-tasks.yaml @@ -26,6 +26,9 @@ overrides: - osd down - OBJECT_UNFOUND - OSD_HOST_DOWN + - POOL_FULL + - pool full + - pool\(s\) full tasks: - install: branch: tentacle diff --git a/qa/suites/upgrade/tentacle-x/parallel/0-start.yaml b/qa/suites/upgrade/tentacle-x/parallel/0-start.yaml index 68945102eef..415a09e8db0 100644 --- a/qa/suites/upgrade/tentacle-x/parallel/0-start.yaml +++ b/qa/suites/upgrade/tentacle-x/parallel/0-start.yaml @@ -59,3 +59,6 @@ overrides: - OBJECT_UNFOUND - PG_DEGRADED - OSD_HOST_DOWN + - POOL_FULL + - pool full + - pool\(s\) full diff --git a/qa/suites/upgrade/tentacle-x/stress-split/1-start.yaml b/qa/suites/upgrade/tentacle-x/stress-split/1-start.yaml index 9d8a4fbda7d..694f25019b7 100644 --- a/qa/suites/upgrade/tentacle-x/stress-split/1-start.yaml +++ b/qa/suites/upgrade/tentacle-x/stress-split/1-start.yaml @@ -36,6 +36,9 @@ overrides: - CEPHADM_FAILED_DAEMON - failed cephadm daemon - osd.* is in unknown state + - POOL_FULL + - pool full + - pool\(s\) full tasks: - install: branch: tentacle From a1afaa6529ce6e4a980a9634c8c8c4ceb201cb42 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 15:46:05 +0800 Subject: [PATCH 390/596] cmake/boost: build libboost_context with BOOST_USE_ASAN The ceph-api job failed under ASan: radosgw-admin aborted at startup with a heap-buffer-overflow in boost.context's fiber resume(). ==1155842==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 8 #0 boost::context::detail::fiber_activation_record::resume() fiber_ucontext.hpp:153 #10 RGWSI_Notify::do_start(optional_yield, DoutPrefixProvider const*) svc_notify.cc:261 0x... is located 8 bytes after 1048-byte region allocated by boost::context::detail::fiber_activation_record_initializer() fiber_activation_record carries three extra members (fake_stack, stack_bottom, stack_size) only when BOOST_USE_ASAN is defined. Under WITH_ASAN we build boost.context with context-impl=ucontext and give consumers BOOST_USE_ASAN and BOOST_USE_UCONTEXT through Boost::context's INTERFACE_COMPILE_DEFINITIONS, but we never pass BOOST_USE_ASAN to b2. So libboost_context, which compiles fiber_activation_record_initializer() and allocates the record, uses the smaller no-ASan layout, while consumers that include the header (here rgw's RGWSI_Notify::do_start via boost::asio::spawn) compile resume() with the larger layout and read stack_bottom/stack_size past the allocation. Pass define=BOOST_USE_ASAN to b2 so the library and its consumers agree on the struct layout. Signed-off-by: Kefu Chai --- CMakeLists.txt | 7 +------ cmake/modules/BuildBoost.cmake | 6 ++++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f2abb516b65..cb422fd759c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -825,12 +825,7 @@ else() # Boost.Context is built ucontext-only here (context-impl=ucontext); define # the matching backend tree-wide so every Boost.Context/Coroutine2 consumer # agrees on it, instead of relying on per-target propagation that is easy to miss. - # Except riscv64: its ASan mis-handles ucontext, so it keeps fcontext. - if(CMAKE_SYSTEM_PROCESSOR MATCHES "riscv") - add_compile_definitions(BOOST_USE_ASAN) - else() - add_compile_definitions(BOOST_USE_ASAN BOOST_USE_UCONTEXT) - endif() + add_compile_definitions(BOOST_USE_ASAN BOOST_USE_UCONTEXT) endif() endif() include_directories(BEFORE SYSTEM ${Boost_INCLUDE_DIRS}) diff --git a/cmake/modules/BuildBoost.cmake b/cmake/modules/BuildBoost.cmake index 6f409dcdce1..6a00c6be2c0 100644 --- a/cmake/modules/BuildBoost.cmake +++ b/cmake/modules/BuildBoost.cmake @@ -146,9 +146,11 @@ function(do_build_boost root_dir version) endif() set(b2_targets headers stage) set(b2_install_targets install) - # Except riscv64: its ASan mis-handles ucontext, so it keeps fcontext. - if(WITH_ASAN AND NOT (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv")) + if(WITH_ASAN) list(APPEND b2 context-impl=ucontext) + # build the library with the BOOST_USE_ASAN consumers get from Boost::context, + # so fiber_activation_record has one layout (else heap-buffer-overflow) + list(APPEND b2 define=BOOST_USE_ASAN) # `context-impl` is declared in libs/context/build/Jamfile.v2; the headers/stage # and install targets never load it, so b2 aborts with `unknown feature # ""`. Name the context project as a target so its Jamfile loads From a33b19eb68f920afbf0522d9de3b8002e3f1d649 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Wed, 24 Jun 2026 07:11:24 +0800 Subject: [PATCH 391/596] script: fix invalid escape sequences Python 3.12 warns on invalid escape sequences in non-raw strings: ``` ceph-release-notes:45: SyntaxWarning: invalid escape sequence '\d' cpatch.py:465: SyntaxWarning: invalid escape sequence '\;' ``` In `cpatch.py`, use raw strings for the two Dockerfile RUN lines so their find -exec ... \; keeps the literal backslash-semicolon. Signed-off-by: Kefu Chai --- src/script/cpatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/cpatch.py b/src/script/cpatch.py index 5e33e9b2c13..b891c8c70ea 100755 --- a/src/script/cpatch.py +++ b/src/script/cpatch.py @@ -462,12 +462,12 @@ class Builder: if os.path.isdir(self._workdir / "tmp_bin"): # For every binary file that was copied to the tmp_bin directory by the jobs above, search for the existing file in the container and replace it. dlines.append("ADD tmp_bin /tmpbin") - dlines.append("RUN for i in tmpbin/*; do find /usr/bin /usr/sbin -name $(basename $i) -exec mv -f $i '{}' \;; echo $(basename $i); done && rm -rf tmpbin") + dlines.append(r"RUN for i in tmpbin/*; do find /usr/bin /usr/sbin -name $(basename $i) -exec mv -f $i '{}' \;; echo $(basename $i); done && rm -rf tmpbin") if os.path.isdir(self._workdir / "tmp_lib"): # For every library file that was copied to the tmp_lib directory by the jobs above, search for the existing file in the container and replace it. dlines.append("ADD tmp_lib /tmplib") - dlines.append("RUN for i in tmplib/*; do find /usr/lib64 -name $(basename $i) -exec mv -f $i '{}' \;; echo $(basename $i); done && rm -rf tmplib") + dlines.append(r"RUN for i in tmplib/*; do find /usr/lib64 -name $(basename $i) -exec mv -f $i '{}' \;; echo $(basename $i); done && rm -rf tmplib") if os.path.isdir(self._workdir / "tmp_bin"): # by default locally built binaries assume /usr/local From 095cff07beab9dc98c982a46358ef1a728900126 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Tue, 23 Jun 2026 08:13:37 +0800 Subject: [PATCH 392/596] crimson/common: use the value_map passed to dump_metric_value_map() dump_metric_value_map() takes a value_map by const reference but ignored it and iterated seastar::scollectd::get_value_map() instead. Both callers already fetch the map and pass it in, so the map was fetched twice per call and the argument was dead. Iterate vmap. The output is unchanged, since the callers pass get_value_map(), but the parameter now means what it says and the redundant fetch is gone. Signed-off-by: Kefu Chai --- src/crimson/common/metrics_helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crimson/common/metrics_helpers.h b/src/crimson/common/metrics_helpers.h index bd63404c102..199d5c79da8 100644 --- a/src/crimson/common/metrics_helpers.h +++ b/src/crimson/common/metrics_helpers.h @@ -80,7 +80,7 @@ void dump_metric_value_map( F &&filter) { assert(f); - for (const auto& [full_name, metric_family]: seastar::scollectd::get_value_map()) { + for (const auto& [full_name, metric_family]: vmap) { if (!std::invoke(filter, full_name)) { continue; } From deabf21144054ada7e6eb64d35c38adc44db130a Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Wed, 10 Jun 2026 12:51:14 +0200 Subject: [PATCH 393/596] node-proxy: add temperatures and fan speed This adds the temperatures category and fan speed information. Fixes: https://tracker.ceph.com/issues/77408 Signed-off-by: Guillaume Abrioux --- doc/hardware-monitoring/index.rst | 2 ++ src/ceph-node-proxy/ceph_node_proxy/api.py | 6 ++++ .../ceph_node_proxy/baseredfishsystem.py | 28 ++++++++++++++++++- .../ceph_node_proxy/basesystem.py | 3 ++ .../ceph_node_proxy/protocols.py | 2 ++ .../ceph_node_proxy/redfish.py | 15 +++++++++- .../tests/test_baseredfishsystem.py | 16 +++++++++++ src/pybind/mgr/cephadm/agent.py | 10 +++++++ .../mgr/cephadm/tests/node_proxy_data.py | 2 +- .../mgr/cephadm/tests/test_node_proxy.py | 12 ++++++++ .../src/app/shared/enum/hardware.enum.ts | 3 +- src/pybind/mgr/dashboard/services/hardware.py | 2 +- src/pybind/mgr/orchestrator/module.py | 8 ++++-- 13 files changed, 101 insertions(+), 8 deletions(-) diff --git a/doc/hardware-monitoring/index.rst b/doc/hardware-monitoring/index.rst index de94f6ee6aa..033b89aa610 100644 --- a/doc/hardware-monitoring/index.rst +++ b/doc/hardware-monitoring/index.rst @@ -88,6 +88,7 @@ Supported categories are: * network * power * fans +* temperatures * firmwares * criticals @@ -187,6 +188,7 @@ For Developers .. automethod:: NodeProxyEndpoint.power .. automethod:: NodeProxyEndpoint.processors .. automethod:: NodeProxyEndpoint.fans +.. automethod:: NodeProxyEndpoint.temperatures .. automethod:: NodeProxyEndpoint.firmwares .. automethod:: NodeProxyEndpoint.led diff --git a/src/ceph-node-proxy/ceph_node_proxy/api.py b/src/ceph-node-proxy/ceph_node_proxy/api.py index 0bb700820f0..e51a1a33977 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/api.py +++ b/src/ceph-node-proxy/ceph_node_proxy/api.py @@ -110,6 +110,12 @@ class API(Server): def fans(self) -> Dict[str, Any]: return {"fans": self.backend.get_fans()} + @cherrypy.expose + @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.tools.json_out() + def temperatures(self) -> Dict[str, Any]: + return {"temperatures": self.backend.get_temperatures()} + @cherrypy.expose @cherrypy.tools.allow(methods=["GET"]) @cherrypy.tools.json_out() diff --git a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py index 4c4051951f1..15b513da6f0 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py @@ -33,7 +33,20 @@ class BaseRedfishSystem(BaseSystem): "Status", ] POWER_FIELDS: List[str] = ["Name", "Model", "Manufacturer", "Status"] - FANS_FIELDS: List[str] = ["Name", "PhysicalContext", "Status"] + FANS_FIELDS: List[str] = [ + "Name", + "PhysicalContext", + "Reading", + "ReadingUnits", + "Status", + ] + TEMPERATURES_FIELDS: List[str] = [ + "Name", + "PhysicalContext", + "Reading", + "ReadingUnits", + "Status", + ] FIRMWARES_FIELDS: List[str] = [ "Name", "Description", @@ -63,6 +76,11 @@ class BaseRedfishSystem(BaseSystem): "fans": [ ComponentUpdateSpec("chassis", "Thermal", FANS_FIELDS, "Fans"), ], + "temperatures": [ + ComponentUpdateSpec( + "chassis", "Thermal", TEMPERATURES_FIELDS, "Temperatures" + ), + ], "firmwares": [ ComponentUpdateSpec( "update_service", "FirmwareInventory", FIRMWARES_FIELDS, None @@ -102,6 +120,7 @@ class BaseRedfishSystem(BaseSystem): "memory", "power", "fans", + "temperatures", "network", "processors", "storage", @@ -209,6 +228,7 @@ class BaseRedfishSystem(BaseSystem): "memory": self.get_memory(), "power": self.get_power(), "fans": self.get_fans(), + "temperatures": self.get_temperatures(), }, "firmwares": self.get_firmwares(), } @@ -251,6 +271,9 @@ class BaseRedfishSystem(BaseSystem): def get_fans(self) -> Dict[str, Dict[str, Dict]]: return dict(self._sys.get("fans", {})) + def get_temperatures(self) -> Dict[str, Dict[str, Dict]]: + return dict(self._sys.get("temperatures", {})) + def get_component_spec_overrides(self) -> Dict[str, Dict[str, Any]]: return {} @@ -361,6 +384,9 @@ class BaseRedfishSystem(BaseSystem): def _update_fans(self) -> None: self._run_update("fans") + def _update_temperatures(self) -> None: + self._run_update("temperatures") + def _update_firmwares(self) -> None: self._run_update("firmwares") diff --git a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py index 3d02a7d4fad..025638b5cde 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py @@ -36,6 +36,9 @@ class BaseSystem(BaseThread): def get_fans(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() + def get_temperatures(self) -> Dict[str, Dict[str, Dict]]: + raise NotImplementedError() + def get_power(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() diff --git a/src/ceph-node-proxy/ceph_node_proxy/protocols.py b/src/ceph-node-proxy/ceph_node_proxy/protocols.py index e8b91d76051..2b322e8ed7e 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/protocols.py +++ b/src/ceph-node-proxy/ceph_node_proxy/protocols.py @@ -16,6 +16,8 @@ class SystemBackend(Protocol): def get_fans(self) -> Dict[str, Any]: ... + def get_temperatures(self) -> Dict[str, Any]: ... + def get_firmwares(self) -> Dict[str, Any]: ... def get_led(self) -> Dict[str, Any]: ... diff --git a/src/ceph-node-proxy/ceph_node_proxy/redfish.py b/src/ceph-node-proxy/ceph_node_proxy/redfish.py index d51bf48d104..bbf17dad71e 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/redfish.py +++ b/src/ceph-node-proxy/ceph_node_proxy/redfish.py @@ -231,6 +231,14 @@ class Endpoint: ) +def is_absent_member(item: Dict[str, Any]) -> bool: + status = item.get("Status") + if not isinstance(status, dict): + return False + state = status.get("State") + return isinstance(state, str) and state.lower() == "absent" + + def build_data( data: Dict[str, Any], fields: List[str], @@ -247,6 +255,10 @@ def build_data( except KeyError: log.debug(f"Could not find field: {field} in data: {d}") out[to_snake_case(field)] = None + if out.get("reading") is None and "ReadingCelsius" in d: + out["reading"] = d["ReadingCelsius"] + if out.get("reading_units") is None: + out["reading_units"] = "Cel" return out try: @@ -256,8 +268,9 @@ def build_data( data_items = [{"MemberId": k, **v} for k, v in data.items()] log.debug(f"build_data: data_items count={len(data_items)}") for d in data_items: + if is_absent_member(d): + continue member_id = d.get("MemberId") - result[member_id] = {} result[member_id] = process_data(member_id, fields, d) except (KeyError, TypeError, AttributeError) as e: log.error(f"Can't build data: {e}") diff --git a/src/ceph-node-proxy/tests/test_baseredfishsystem.py b/src/ceph-node-proxy/tests/test_baseredfishsystem.py index adac813a54c..2939081935c 100644 --- a/src/ceph-node-proxy/tests/test_baseredfishsystem.py +++ b/src/ceph-node-proxy/tests/test_baseredfishsystem.py @@ -48,6 +48,7 @@ class TestBaseRedfishSystemInit: assert "storage" in system.component_list assert "firmwares" in system.component_list assert "fans" in system.component_list + assert "temperatures" in system.component_list def test_init_update_funcs_populated(self, system): # Should have one callable per component that has _update_ @@ -106,6 +107,9 @@ class TestBaseRedfishSystemGetters: def test_get_fans_empty(self, system): assert system.get_fans() == {} + def test_get_temperatures_empty(self, system): + assert system.get_temperatures() == {} + def test_get_firmwares_empty(self, system): assert system.get_firmwares() == {} @@ -122,6 +126,7 @@ class TestBaseRedfishSystemGetSystem: system._sys["storage"] = {} system._sys["power"] = {} system._sys["fans"] = {} + system._sys["temperatures"] = {} system._sys["firmwares"] = {} result = system.get_system() assert "host" in result @@ -134,6 +139,7 @@ class TestBaseRedfishSystemGetSystem: assert result["status"]["storage"] == {} assert result["status"]["power"] == {} assert result["status"]["fans"] == {} + assert result["status"]["temperatures"] == {} assert "firmwares" in result @@ -170,6 +176,15 @@ class TestBaseRedfishSystemGetSpecs: assert specs[0].collection == "chassis" assert specs[0].path == "Thermal" assert specs[0].attribute == "Fans" + assert "Reading" in specs[0].fields + + def test_get_specs_temperatures(self, system): + specs = system.get_specs("temperatures") + assert len(specs) == 1 + assert specs[0].collection == "chassis" + assert specs[0].path == "Thermal" + assert specs[0].attribute == "Temperatures" + assert "Reading" in specs[0].fields def test_get_component_spec_overrides_empty(self, system): assert system.get_component_spec_overrides() == {} @@ -232,6 +247,7 @@ class TestBaseRedfishSystemComponentSpecs: assert "memory" in BaseRedfishSystem.COMPONENT_SPECS assert "power" in BaseRedfishSystem.COMPONENT_SPECS assert "fans" in BaseRedfishSystem.COMPONENT_SPECS + assert "temperatures" in BaseRedfishSystem.COMPONENT_SPECS assert "firmwares" in BaseRedfishSystem.COMPONENT_SPECS def test_field_lists_non_empty(self): diff --git a/src/pybind/mgr/cephadm/agent.py b/src/pybind/mgr/cephadm/agent.py index d767f1b24b5..166d9ba48c0 100644 --- a/src/pybind/mgr/cephadm/agent.py +++ b/src/pybind/mgr/cephadm/agent.py @@ -612,6 +612,16 @@ class NodeProxyEndpoint: raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.") return results + @cherrypy.expose + @cherrypy.tools.allow(methods=['GET']) + @cherrypy.tools.json_out() + def temperatures(self, **kw: Any) -> Dict[str, Any]: + try: + results = self.mgr.node_proxy_cache.common('temperatures', **kw) + except (KeyError, OrchestratorError): + raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.") + return results + @cherrypy.expose @cherrypy.tools.allow(methods=['GET']) @cherrypy.tools.json_out() diff --git a/src/pybind/mgr/cephadm/tests/node_proxy_data.py b/src/pybind/mgr/cephadm/tests/node_proxy_data.py index fa768f1d4c6..6f103dbeba1 100644 --- a/src/pybind/mgr/cephadm/tests/node_proxy_data.py +++ b/src/pybind/mgr/cephadm/tests/node_proxy_data.py @@ -1,3 +1,3 @@ full_set_with_critical = {'host': 'host01', 'sn': '12345', 'status': {'storage': {'1': {'disk.bay.0:enclosure.internal.0-1:raid.integrated.1-1': {'description': 'Solid State Disk 0:1:0', 'entity': 'RAID.Integrated.1-1', 'capacity_bytes': 959656755200, 'model': 'KPM5XVUG960G', 'protocol': 'SAS', 'serial_number': '8080A1CRTP5F', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 0, 'locationtype': 'Slot'}}}, 'disk.bay.9:enclosure.internal.0-1': {'description': 'PCIe SSD in Slot 9 in Bay 1', 'entity': 'CPU.1', 'capacity_bytes': 1600321314816, 'model': 'Dell Express Flash NVMe P4610 1.6TB SFF', 'protocol': 'PCIe', 'serial_number': 'PHLN035305MN1P6AGN', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 9, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 20, 'total_threads': 40, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Gold 6230 CPU @ 2.10GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'nic.slot.1-1-1': {'description': 'NIC in Slot 1 Port 1 Partition 1', 'name': 'System Ethernet Interface', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'StandbyOffline'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 31237, 'status': {'health': 'Critical', 'state': 'Enabled'}}}}}, 'firmwares': {}} mgr_inventory_cache = {'host01': {'hostname': 'host01', 'addr': '10.10.10.11', 'labels': ['_admin'], 'status': '', 'oob': {'hostname': '10.10.10.11', 'username': 'root', 'password': 'ceph123'}}, 'host02': {'hostname': 'host02', 'addr': '10.10.10.12', 'labels': [], 'status': '', 'oob': {'hostname': '10.10.10.12', 'username': 'root', 'password': 'ceph123'}}} -full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} +full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} diff --git a/src/pybind/mgr/cephadm/tests/test_node_proxy.py b/src/pybind/mgr/cephadm/tests/test_node_proxy.py index 75fe7d6fab9..dd6593fd273 100644 --- a/src/pybind/mgr/cephadm/tests/test_node_proxy.py +++ b/src/pybind/mgr/cephadm/tests/test_node_proxy.py @@ -319,6 +319,18 @@ class TestNodeProxyEndpoint(helper.CPWebCase): self.getPage("/host03/fans", method="GET") self.assertStatus('404 Not Found') + def test_temperatures_with_valid_hostname(self): + self.getPage("/host02/temperatures", method="GET") + self.assertStatus('200 OK') + + def test_temperatures_no_hostname(self): + self.getPage("/temperatures", method="GET") + self.assertStatus('200 OK') + + def test_temperatures_with_invalid_hostname(self): + self.getPage("/host03/temperatures", method="GET") + self.assertStatus('404 Not Found') + def test_firmwares_with_valid_hostname(self): self.getPage("/host02/firmwares", method="GET") self.assertStatus('200 OK') diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts index 7956dfa5d7c..ae115d05001 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts @@ -4,5 +4,6 @@ export enum HardwareNameMapping { processors = 'CPU', network = 'Network', power = 'Power supply', - fans = 'Fan module' + fans = 'Fan module', + temperatures = 'Temperature' } diff --git a/src/pybind/mgr/dashboard/services/hardware.py b/src/pybind/mgr/dashboard/services/hardware.py index 31054ab4cc7..2aa89f422a3 100644 --- a/src/pybind/mgr/dashboard/services/hardware.py +++ b/src/pybind/mgr/dashboard/services/hardware.py @@ -75,7 +75,7 @@ class HardwareService(object): @staticmethod def validate_categories(categories: Optional[List[str]]) -> List[str]: categories_list = ['memory', 'storage', 'processors', - 'network', 'power', 'fans'] + 'network', 'power', 'fans', 'temperatures'] if isinstance(categories, str): categories = [categories] diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index 8a1cbea42fc..abb5f205435 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -534,7 +534,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'processors': ['HOST', 'SYS_ID', 'NAME', 'MODEL', 'CORES', 'THREADS', 'STATUS', 'STATE'], 'network': ['HOST', 'SYS_ID', 'NAME', 'SPEED', 'STATUS', 'STATE'], 'power': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'MODEL', 'MANUFACTURER', 'STATUS', 'STATE'], - 'fans': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'STATUS', 'STATE'] + 'fans': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], + 'temperatures': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], } if category not in table_heading_mapping.keys(): @@ -619,7 +620,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'processors': ('model', 'total_cores', 'total_threads', 'health', 'state'), 'network': ('name', 'speed_mbps', 'health', 'state'), 'power': ('name', 'model', 'manufacturer', 'health', 'state'), - 'fans': ('name', 'health', 'state') + 'fans': ('name', 'reading', 'reading_units', 'health', 'state'), + 'temperatures': ('name', 'reading', 'reading_units', 'health', 'state'), } fields = mapping.get(category, ()) @@ -634,7 +636,7 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): row.append(v['status'][field]) else: row.append('') - if category in ('power', 'fans', 'processors'): + if category in ('power', 'fans', 'temperatures', 'processors'): table.add_row((host, sys_id,) + (k,) + tuple(row)) else: table.add_row((host, sys_id,) + tuple(row)) From 9b3bfd171afe6714cb23dc0fe55bacad671a94dc Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Wed, 10 Jun 2026 12:51:47 +0200 Subject: [PATCH 394/596] node-proxy: override with atollon specific This adds AtollonSystem for AMI/Atollon BMCs: - memory Id mapping, - storage description fixes, - StorageControllers based drive enrichment It also wires vendor selection through cephadm (hw_monitoring_vendor) and show slot/firmware in hardware status storage output. Fixes: https://tracker.ceph.com/issues/77408 Signed-off-by: Guillaume Abrioux --- .../ceph_node_proxy/atollon.py | 118 ++++++++++++- .../ceph_node_proxy/bootstrap.py | 7 +- src/ceph-node-proxy/ceph_node_proxy/config.py | 3 + src/ceph-node-proxy/ceph_node_proxy/main.py | 1 + src/ceph-node-proxy/tests/test_atollon.py | 165 ++++++++++++++++++ src/ceph-node-proxy/tests/test_config.py | 61 +++++++ src/ceph-node-proxy/tests/test_redfish.py | 63 +++++++ src/pybind/mgr/cephadm/module.py | 7 + src/pybind/mgr/cephadm/services/node_proxy.py | 1 + src/pybind/mgr/orchestrator/module.py | 4 +- 10 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 src/ceph-node-proxy/tests/test_atollon.py create mode 100644 src/ceph-node-proxy/tests/test_config.py create mode 100644 src/ceph-node-proxy/tests/test_redfish.py diff --git a/src/ceph-node-proxy/ceph_node_proxy/atollon.py b/src/ceph-node-proxy/ceph_node_proxy/atollon.py index f870d455d18..32aa249b4be 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/atollon.py +++ b/src/ceph-node-proxy/ceph_node_proxy/atollon.py @@ -1,10 +1,126 @@ -from typing import Any +import re +from typing import Any, Dict, Optional from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem from ceph_node_proxy.util import get_logger +INVALID_SERIALS = ("unknown", "not available", "n/a", "") + class AtollonSystem(BaseRedfishSystem): def __init__(self, **kw: Any) -> None: super().__init__(**kw) self.log = get_logger(__name__) + + def get_component_spec_overrides(self) -> Dict[str, Dict[str, Any]]: + return { + "memory": { + "fields": [ + "Id" if field == "Description" else field + for field in BaseRedfishSystem.MEMORY_FIELDS + ], + }, + } + + def _update_memory(self) -> None: + super()._update_memory() + for members in self._sys.get("memory", {}).values(): + for member_data in members.values(): + member_id = member_data.get("id") + if member_id is not None and member_data.get("description") is None: + member_data["description"] = member_id + + def _update_storage(self) -> None: + super()._update_storage() + self.fix_storage_descriptions() + self.enrich_storage_from_controllers() + + def fix_storage_descriptions(self) -> None: + for members in self._sys.get("storage", {}).values(): + for drive_id, drive_data in members.items(): + description = drive_data.get("description") + if description is not None and description != "unknown": + continue + endpoint = drive_data.get("redfish_endpoint", "") + if isinstance(endpoint, str) and endpoint: + drive_data["description"] = endpoint.rstrip("/").split("/")[-1] + else: + drive_data["description"] = drive_id + + def enrich_storage_from_controllers(self) -> None: + for member in self.endpoints["systems"].get_members_names(): + drives = self._sys.get("storage", {}).get(member) + if not drives: + continue + storage_units = self.endpoints["systems"][member]["Storage"].get_members_data() + for entity, storage_unit in storage_units.items(): + controllers = storage_unit.get("StorageControllers") or [] + by_serial: Dict[str, Dict[str, Any]] = {} + by_member_id: Dict[str, Dict[str, Any]] = {} + for controller in controllers: + member_id = controller.get("MemberId") + if member_id is not None: + by_member_id[str(member_id)] = controller + serial = controller.get("SerialNumber") + if isinstance(serial, str) and serial.lower() not in INVALID_SERIALS: + by_serial[serial] = controller + for drive_key, drive_data in drives.items(): + if drive_data.get("entity") != entity: + continue + controller = self.match_storage_controller( + drive_key, drive_data, by_serial, by_member_id + ) + if controller is not None: + self.apply_storage_controller(drive_data, controller) + + def match_storage_controller( + self, + drive_key: str, + drive_data: Dict[str, Any], + by_serial: Dict[str, Dict[str, Any]], + by_member_id: Dict[str, Dict[str, Any]], + ) -> Optional[Dict[str, Any]]: + serial = drive_data.get("serial_number") + if isinstance(serial, str) and serial.lower() not in INVALID_SERIALS: + controller = by_serial.get(serial) + if controller is not None: + return controller + description = drive_data.get("description") + device_index = self.parse_drive_device_index(drive_key, description) + if device_index is not None: + return by_member_id.get(device_index) + return None + + def parse_drive_device_index( + self, drive_key: str, description: Any + ) -> Optional[str]: + for text in (description, drive_key): + if not isinstance(text, str): + continue + match = re.search(r"Device(\d+)", text, re.IGNORECASE) + if match: + return match.group(1) + return None + + def apply_storage_controller( + self, drive_data: Dict[str, Any], controller: Dict[str, Any] + ) -> None: + firmware_version = controller.get("FirmwareVersion") + if firmware_version is not None: + drive_data["firmware_version"] = firmware_version + member_id = controller.get("MemberId") + if member_id is not None: + drive_data["slot"] = member_id + if drive_data.get("physical_location") in (None, "unknown"): + slot_value: Any = ( + int(member_id) if str(member_id).isdigit() else member_id + ) + drive_data["physical_location"] = { + "partlocation": { + "locationordinalvalue": slot_value, + "locationtype": "Slot", + } + } + speed_gbps = controller.get("SpeedGbps") + if speed_gbps is not None: + drive_data["speed_gbps"] = speed_gbps diff --git a/src/ceph-node-proxy/ceph_node_proxy/bootstrap.py b/src/ceph-node-proxy/ceph_node_proxy/bootstrap.py index 352d9c562b0..0e85718c5c0 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/bootstrap.py +++ b/src/ceph-node-proxy/ceph_node_proxy/bootstrap.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from ceph_node_proxy.config import CephadmConfig, get_node_proxy_config -from ceph_node_proxy.util import DEFAULTS, write_tmp_file +from ceph_node_proxy.util import DEFAULTS, _deep_merge, write_tmp_file if TYPE_CHECKING: from ceph_node_proxy.main import NodeProxyManager @@ -18,9 +18,12 @@ def create_node_proxy_manager(cephadm_config: CephadmConfig) -> "NodeProxyManage cephadm_config.root_cert_pem, prefix_name="cephadm-endpoint-root-cert-", ) + defaults = DEFAULTS + if cephadm_config.system: + defaults = _deep_merge(DEFAULTS, {"system": cephadm_config.system}) config = get_node_proxy_config( path=cephadm_config.node_proxy_config_path, - defaults=DEFAULTS, + defaults=defaults, ) manager = NodeProxyManager( diff --git a/src/ceph-node-proxy/ceph_node_proxy/config.py b/src/ceph-node-proxy/ceph_node_proxy/config.py index 50bb3a89996..c331e70e3cc 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/config.py +++ b/src/ceph-node-proxy/ceph_node_proxy/config.py @@ -30,6 +30,7 @@ class CephadmConfig: listener_key: str name: str node_proxy_config_path: str + system: Dict[str, Any] @classmethod def from_dict(cls, data: Dict[str, Any]) -> CephadmConfig: @@ -41,6 +42,7 @@ class CephadmConfig: "NODE_PROXY_CONFIG", "/etc/ceph/node-proxy.yml" ) assert node_proxy_config_path is not None + system = data.get("system") return cls( target_ip=data["target_ip"], target_port=data["target_port"], @@ -50,6 +52,7 @@ class CephadmConfig: listener_key=data["listener.key"], name=data["name"], node_proxy_config_path=node_proxy_config_path, + system=system if isinstance(system, dict) else {}, ) diff --git a/src/ceph-node-proxy/ceph_node_proxy/main.py b/src/ceph-node-proxy/ceph_node_proxy/main.py index a7869a4119c..6f0ab0b593d 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/main.py +++ b/src/ceph-node-proxy/ceph_node_proxy/main.py @@ -104,6 +104,7 @@ class NodeProxyManager: raise SystemExit(1) try: vendor = self.config.get("system", {}).get("vendor", "generic") + self.log.info("Using Redfish vendor: %s", vendor) system_cls = get_system_class(vendor) self.system = system_cls( host=oob_details["host"], diff --git a/src/ceph-node-proxy/tests/test_atollon.py b/src/ceph-node-proxy/tests/test_atollon.py new file mode 100644 index 00000000000..7c4b9a179e1 --- /dev/null +++ b/src/ceph-node-proxy/tests/test_atollon.py @@ -0,0 +1,165 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from ceph_node_proxy.atollon import AtollonSystem +from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem + + +@pytest.fixture +def atollon_system(): + with ( + patch("ceph_node_proxy.baseredfishsystem.RedFishClient", return_value=MagicMock()), + patch("ceph_node_proxy.baseredfishsystem.EndpointMgr", return_value=MagicMock()), + ): + return AtollonSystem( + host="testhost", + port="443", + username="user", + password="secret", + config={}, + ) + + +class TestAtollonSystemMemoryOverrides: + def test_get_specs_memory_uses_id_instead_of_description(self, atollon_system): + specs = atollon_system.get_specs("memory") + assert len(specs) == 1 + assert "Id" in specs[0].fields + assert "Description" not in specs[0].fields + assert "MemoryDeviceType" in specs[0].fields + + def test_update_memory_maps_id_to_description(self, atollon_system): + atollon_system._sys["memory"] = { + "1": { + "dimm0": { + "id": "dimm0", + "memory_device_type": "DDR4", + "capacity_mib": 16384, + "status": {"health": "OK", "state": "Enabled"}, + }, + }, + } + + with patch.object(BaseRedfishSystem, "_update_memory") as mock_super: + mock_super.side_effect = lambda: None + atollon_system._update_memory() + + assert atollon_system._sys["memory"]["1"]["dimm0"]["description"] == "dimm0" + + +class TestAtollonSystemStorageOverrides: + def test_update_storage_replaces_unknown_description(self, atollon_system): + atollon_system._sys["storage"] = { + "Self": { + "nvme_device0_nsid1": { + "description": "unknown", + "model": "Micron_2550_MTFDKBK512TGE", + "capacity_bytes": 512110190592, + "protocol": "NVMe", + "serial_number": "24424BAA3C40", + "status": {"health": "OK", "state": "Enabled"}, + "redfish_endpoint": ( + "/redfish/v1/Systems/Self/Storage/StorageUnit_0/" + "Drives/NVMe_Device0_NSID1" + ), + "entity": "StorageUnit_0", + }, + }, + } + + with patch.object(BaseRedfishSystem, "_update_storage") as mock_super: + mock_super.side_effect = lambda: None + atollon_system.fix_storage_descriptions() + + drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + assert drive["description"] == "NVMe_Device0_NSID1" + + def test_enrich_storage_from_controllers_by_serial(self, atollon_system): + atollon_system._sys["storage"] = { + "Self": { + "nvme_device0_nsid1": { + "description": "NVMe_Device0_NSID1", + "model": "Micron_2550_MTFDKBK512TGE", + "serial_number": "24424BAA3C40", + "entity": "StorageUnit_0", + "physical_location": "unknown", + }, + }, + } + mock_storage = MagicMock() + mock_storage.get_members_data.return_value = { + "StorageUnit_0": { + "StorageControllers": [ + { + "MemberId": "0", + "SerialNumber": "24424BAA3C40", + "FirmwareVersion": "V6MA001", + "SpeedGbps": 63.02, + }, + ], + }, + } + member_endpoint = MagicMock() + member_endpoint.__getitem__ = MagicMock( + side_effect=lambda key: mock_storage if key == "Storage" else MagicMock() + ) + systems_endpoint = MagicMock() + systems_endpoint.get_members_names.return_value = ["Self"] + systems_endpoint.__getitem__ = MagicMock( + side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() + ) + atollon_system.endpoints = MagicMock() + atollon_system.endpoints.__getitem__ = MagicMock( + side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() + ) + + atollon_system.enrich_storage_from_controllers() + + drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + assert drive["firmware_version"] == "V6MA001" + assert drive["slot"] == "0" + assert drive["speed_gbps"] == 63.02 + assert drive["physical_location"]["partlocation"]["locationordinalvalue"] == 0 + + def test_enrich_storage_from_controllers_by_device_index(self, atollon_system): + atollon_system._sys["storage"] = { + "Self": { + "nvme_device2_nsid1": { + "description": "NVMe_Device2_NSID1", + "serial_number": "unknown", + "entity": "StorageUnit_0", + }, + }, + } + mock_storage = MagicMock() + mock_storage.get_members_data.return_value = { + "StorageUnit_0": { + "StorageControllers": [ + { + "MemberId": "2", + "SerialNumber": "03NK797YS344D57S056", + "FirmwareVersion": "000A5305", + }, + ], + }, + } + member_endpoint = MagicMock() + member_endpoint.__getitem__ = MagicMock( + side_effect=lambda key: mock_storage if key == "Storage" else MagicMock() + ) + systems_endpoint = MagicMock() + systems_endpoint.get_members_names.return_value = ["Self"] + systems_endpoint.__getitem__ = MagicMock( + side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() + ) + atollon_system.endpoints = MagicMock() + atollon_system.endpoints.__getitem__ = MagicMock( + side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() + ) + + atollon_system.enrich_storage_from_controllers() + + drive = atollon_system._sys["storage"]["Self"]["nvme_device2_nsid1"] + assert drive["firmware_version"] == "000A5305" + assert drive["slot"] == "2" diff --git a/src/ceph-node-proxy/tests/test_config.py b/src/ceph-node-proxy/tests/test_config.py new file mode 100644 index 00000000000..3fa307f08c8 --- /dev/null +++ b/src/ceph-node-proxy/tests/test_config.py @@ -0,0 +1,61 @@ +import json +import tempfile +from pathlib import Path + +from ceph_node_proxy.config import CephadmConfig, get_node_proxy_config, load_cephadm_config +from ceph_node_proxy.util import DEFAULTS, _deep_merge + + +def test_cephadm_config_parses_system_vendor() -> None: + data = { + "target_ip": "10.0.0.1", + "target_port": "8443", + "keyring": "secret", + "root_cert.pem": "ca", + "listener.crt": "crt", + "listener.key": "key", + "name": "node-proxy.host01", + "system": {"vendor": "atollon"}, + } + config = CephadmConfig.from_dict(data) + assert config.system == {"vendor": "atollon"} + + +def test_load_cephadm_config_from_file() -> None: + payload = { + "target_ip": "10.0.0.1", + "target_port": "8443", + "keyring": "secret", + "root_cert.pem": "ca", + "listener.crt": "crt", + "listener.key": "key", + "name": "node-proxy.host01", + "system": {"vendor": "atollon"}, + } + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "node-proxy.json" + path.write_text(json.dumps(payload)) + config = load_cephadm_config(str(path)) + assert config.system["vendor"] == "atollon" + + +def test_cephadm_system_vendor_merged_into_node_proxy_config() -> None: + cephadm_config = CephadmConfig.from_dict( + { + "target_ip": "10.0.0.1", + "target_port": "8443", + "keyring": "secret", + "root_cert.pem": "ca", + "listener.crt": "crt", + "listener.key": "key", + "name": "node-proxy.host01", + "system": {"vendor": "atollon"}, + } + ) + defaults = _deep_merge(DEFAULTS, {"system": cephadm_config.system}) + with tempfile.TemporaryDirectory() as tmpdir: + config = get_node_proxy_config( + path=str(Path(tmpdir) / "missing.yml"), + defaults=defaults, + ) + assert config.get("system", {}).get("vendor") == "atollon" diff --git a/src/ceph-node-proxy/tests/test_redfish.py b/src/ceph-node-proxy/tests/test_redfish.py new file mode 100644 index 00000000000..bb1a2d39d86 --- /dev/null +++ b/src/ceph-node-proxy/tests/test_redfish.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock + +from ceph_node_proxy.redfish import build_data + + +def test_build_data_thermal_fans_and_temperatures() -> None: + thermal = { + "Fans": [ + { + "MemberId": "0", + "Name": "FAN1_TACH_IN", + "PhysicalContext": "Fan", + "Reading": 23835, + "ReadingUnits": "RPM", + "Status": {"Health": "OK", "State": "Enabled"}, + } + ], + "Temperatures": [ + { + "MemberId": "0", + "Name": "C1_DCSCM_TEMP", + "PhysicalContext": "Intake", + "ReadingCelsius": 31, + "Status": {"Health": "OK", "State": "Enabled"}, + } + ], + } + log = MagicMock() + fields = ["Name", "PhysicalContext", "Reading", "ReadingUnits", "Status"] + + fans = build_data(thermal, fields, log, attribute="Fans") + assert fans["0"]["reading"] == 23835 + assert fans["0"]["reading_units"] == "RPM" + + temps = build_data(thermal, fields, log, attribute="Temperatures") + assert temps["0"]["reading"] == 31 + assert temps["0"]["reading_units"] == "Cel" + + +def test_build_data_skips_absent_members() -> None: + thermal = { + "Temperatures": [ + { + "MemberId": "0", + "Name": "C1_DCSCM_TEMP", + "PhysicalContext": "Intake", + "ReadingCelsius": 31, + "Status": {"Health": "OK", "State": "Enabled"}, + }, + { + "MemberId": "1", + "Name": "C1_DIMMB_TEMP", + "PhysicalContext": "Intake", + "Status": {"State": "Absent"}, + }, + ], + } + log = MagicMock() + fields = ["Name", "PhysicalContext", "Reading", "ReadingUnits", "Status"] + + temps = build_data(thermal, fields, log, attribute="Temperatures") + assert list(temps.keys()) == ["0"] + assert temps["0"]["name"] == "C1_DCSCM_TEMP" diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 96cb2aee2d0..3bd1bbfb73d 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -400,6 +400,12 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): default=False, desc='Whether the hardware monitoring daemon be deployed on every host?' ), + Option( + 'hw_monitoring_vendor', + type='str', + default='generic', + desc='Redfish vendor implementation for node-proxy (generic, dell, atollon).' + ), Option( 'max_osd_draining_count', type='int', @@ -621,6 +627,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): self.agent_down_multiplier = 0.0 self.agent_starting_port = 0 self.hw_monitoring = False + self.hw_monitoring_vendor = 'generic' self.service_discovery_port = 0 self.secure_monitoring_stack = False self.apply_spec_fails: List[Tuple[str, str]] = [] diff --git a/src/pybind/mgr/cephadm/services/node_proxy.py b/src/pybind/mgr/cephadm/services/node_proxy.py index 9b9de4e6e13..908d8fad3c6 100644 --- a/src/pybind/mgr/cephadm/services/node_proxy.py +++ b/src/pybind/mgr/cephadm/services/node_proxy.py @@ -68,6 +68,7 @@ class NodeProxy(CephService): 'root_cert.pem': self.mgr.cert_mgr.get_root_ca(), 'listener.crt': tls_pair.cert, 'listener.key': tls_pair.key, + 'system': {'vendor': self.mgr.hw_monitoring_vendor}, } config = {'node-proxy.json': json.dumps(cfg)} diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index abb5f205435..0930d3f267b 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -530,7 +530,7 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'firmwares': ['HOST', 'COMPONENT', 'NAME', 'DATE', 'VERSION', 'STATUS'], 'criticals': ['HOST', 'SYS_ID', 'COMPONENT', 'NAME', 'STATUS', 'STATE'], 'memory': ['HOST', 'SYS_ID', 'NAME', 'STATUS', 'STATE'], - 'storage': ['HOST', 'SYS_ID', 'NAME', 'MODEL', 'SIZE', 'PROTOCOL', 'SN', 'STATUS', 'STATE'], + 'storage': ['HOST', 'SYS_ID', 'NAME', 'MODEL', 'SIZE', 'PROTOCOL', 'SN', 'SLOT', 'FW', 'STATUS', 'STATE'], 'processors': ['HOST', 'SYS_ID', 'NAME', 'MODEL', 'CORES', 'THREADS', 'STATUS', 'STATE'], 'network': ['HOST', 'SYS_ID', 'NAME', 'SPEED', 'STATUS', 'STATE'], 'power': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'MODEL', 'MANUFACTURER', 'STATUS', 'STATE'], @@ -616,7 +616,7 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): return json.dumps(data) mapping = { 'memory': ('description', 'health', 'state'), - 'storage': ('description', 'model', 'capacity_bytes', 'protocol', 'serial_number', 'health', 'state'), + 'storage': ('description', 'model', 'capacity_bytes', 'protocol', 'serial_number', 'slot', 'firmware_version', 'health', 'state'), 'processors': ('model', 'total_cores', 'total_threads', 'health', 'state'), 'network': ('name', 'speed_mbps', 'health', 'state'), 'power': ('name', 'model', 'manufacturer', 'health', 'state'), From 1cd4b32d72a4a14cbd797187e791e038422f4fc3 Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Mon, 15 Jun 2026 15:18:48 +0200 Subject: [PATCH 395/596] node-proxy: rename firmwares to firmware with legacy aliases Let's use 'firmware' as the standard name in node-proxy, cephadm, and orch hardware status. We still accept 'firmwares' as a deprecated alias and read legacy cache payloads transparently for backward compatibility. Fixes: https://tracker.ceph.com/issues/77410 Signed-off-by: Guillaume Abrioux --- doc/hardware-monitoring/index.rst | 6 ++--- src/ceph-node-proxy/ceph_node_proxy/api.py | 8 ++++++- .../ceph_node_proxy/baseredfishsystem.py | 18 +++++++-------- .../ceph_node_proxy/basesystem.py | 2 +- .../ceph_node_proxy/protocols.py | 2 +- .../tests/test_baseredfishsystem.py | 12 +++++----- src/pybind/mgr/cephadm/agent.py | 12 +++++++--- src/pybind/mgr/cephadm/inventory.py | 12 +++++++--- src/pybind/mgr/cephadm/module.py | 6 ++++- .../mgr/cephadm/tests/node_proxy_data.py | 4 ++-- .../mgr/cephadm/tests/test_node_proxy.py | 22 +++++++++++-------- src/pybind/mgr/orchestrator/_interface.py | 10 ++++++++- src/pybind/mgr/orchestrator/module.py | 13 ++++++----- 13 files changed, 82 insertions(+), 45 deletions(-) diff --git a/doc/hardware-monitoring/index.rst b/doc/hardware-monitoring/index.rst index 033b89aa610..900a9129456 100644 --- a/doc/hardware-monitoring/index.rst +++ b/doc/hardware-monitoring/index.rst @@ -89,7 +89,7 @@ Supported categories are: * power * fans * temperatures -* firmwares +* firmware * criticals @@ -144,7 +144,7 @@ ________________ .. prompt:: bash # auto - # ceph orch hardware status node-10 --category firmwares + # ceph orch hardware status node-10 --category firmware +------------+----------------------------------------------------------------------------+--------------------------------------------------------------+----------------------+-------------+--------+ | HOST | COMPONENT | NAME | DATE | VERSION | STATUS | +------------+----------------------------------------------------------------------------+--------------------------------------------------------------+----------------------+-------------+--------+ @@ -189,6 +189,6 @@ For Developers .. automethod:: NodeProxyEndpoint.processors .. automethod:: NodeProxyEndpoint.fans .. automethod:: NodeProxyEndpoint.temperatures -.. automethod:: NodeProxyEndpoint.firmwares +.. automethod:: NodeProxyEndpoint.firmware .. automethod:: NodeProxyEndpoint.led diff --git a/src/ceph-node-proxy/ceph_node_proxy/api.py b/src/ceph-node-proxy/ceph_node_proxy/api.py index e51a1a33977..563ac88c8f7 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/api.py +++ b/src/ceph-node-proxy/ceph_node_proxy/api.py @@ -116,11 +116,17 @@ class API(Server): def temperatures(self) -> Dict[str, Any]: return {"temperatures": self.backend.get_temperatures()} + @cherrypy.expose + @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.tools.json_out() + def firmware(self) -> Dict[str, Any]: + return {"firmware": self.backend.get_firmware()} + @cherrypy.expose @cherrypy.tools.allow(methods=["GET"]) @cherrypy.tools.json_out() def firmwares(self) -> Dict[str, Any]: - return {"firmwares": self.backend.get_firmwares()} + return {"firmwares": self.backend.get_firmware()} def _cp_dispatch(self, vpath: List[str]) -> "API": if vpath[0] == "led" and len(vpath) > 1: # /led/{type}/{id} diff --git a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py index 15b513da6f0..3da5a0f9398 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py @@ -47,7 +47,7 @@ class BaseRedfishSystem(BaseSystem): "ReadingUnits", "Status", ] - FIRMWARES_FIELDS: List[str] = [ + FIRMWARE_FIELDS: List[str] = [ "Name", "Description", "ReleaseDate", @@ -81,9 +81,9 @@ class BaseRedfishSystem(BaseSystem): "chassis", "Thermal", TEMPERATURES_FIELDS, "Temperatures" ), ], - "firmwares": [ + "firmware": [ ComponentUpdateSpec( - "update_service", "FirmwareInventory", FIRMWARES_FIELDS, None + "update_service", "FirmwareInventory", FIRMWARE_FIELDS, None ), ], } @@ -124,7 +124,7 @@ class BaseRedfishSystem(BaseSystem): "network", "processors", "storage", - "firmwares", + "firmware", ], ) self.update_funcs: List[Callable] = [] @@ -230,7 +230,7 @@ class BaseRedfishSystem(BaseSystem): "fans": self.get_fans(), "temperatures": self.get_temperatures(), }, - "firmwares": self.get_firmwares(), + "firmware": self.get_firmware(), } return result @@ -262,8 +262,8 @@ class BaseRedfishSystem(BaseSystem): def get_storage(self) -> Dict[str, Dict[str, Dict]]: return dict(self._sys.get("storage", {})) - def get_firmwares(self) -> Dict[str, Dict[str, Dict]]: - return dict(self._sys.get("firmwares", {})) + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: + return dict(self._sys.get("firmware", {})) def get_power(self) -> Dict[str, Dict[str, Dict]]: return dict(self._sys.get("power", {})) @@ -387,8 +387,8 @@ class BaseRedfishSystem(BaseSystem): def _update_temperatures(self) -> None: self._run_update("temperatures") - def _update_firmwares(self) -> None: - self._run_update("firmwares") + def _update_firmware(self) -> None: + self._run_update("firmware") def device_led_on(self, device: str) -> int: raise NotImplementedError() diff --git a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py index 025638b5cde..aecc0e137c0 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py @@ -48,7 +48,7 @@ class BaseSystem(BaseThread): def get_storage(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() - def get_firmwares(self) -> Dict[str, Dict[str, Dict]]: + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() def get_sn(self) -> str: diff --git a/src/ceph-node-proxy/ceph_node_proxy/protocols.py b/src/ceph-node-proxy/ceph_node_proxy/protocols.py index 2b322e8ed7e..e82d425bea5 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/protocols.py +++ b/src/ceph-node-proxy/ceph_node_proxy/protocols.py @@ -18,7 +18,7 @@ class SystemBackend(Protocol): def get_temperatures(self) -> Dict[str, Any]: ... - def get_firmwares(self) -> Dict[str, Any]: ... + def get_firmware(self) -> Dict[str, Any]: ... def get_led(self) -> Dict[str, Any]: ... diff --git a/src/ceph-node-proxy/tests/test_baseredfishsystem.py b/src/ceph-node-proxy/tests/test_baseredfishsystem.py index 2939081935c..28a90521406 100644 --- a/src/ceph-node-proxy/tests/test_baseredfishsystem.py +++ b/src/ceph-node-proxy/tests/test_baseredfishsystem.py @@ -46,7 +46,7 @@ class TestBaseRedfishSystemInit: assert "network" in system.component_list assert "processors" in system.component_list assert "storage" in system.component_list - assert "firmwares" in system.component_list + assert "firmware" in system.component_list assert "fans" in system.component_list assert "temperatures" in system.component_list @@ -110,8 +110,8 @@ class TestBaseRedfishSystemGetters: def test_get_temperatures_empty(self, system): assert system.get_temperatures() == {} - def test_get_firmwares_empty(self, system): - assert system.get_firmwares() == {} + def test_get_firmware_empty(self, system): + assert system.get_firmware() == {} def test_get_status_empty(self, system): assert system.get_status() == {} @@ -127,7 +127,7 @@ class TestBaseRedfishSystemGetSystem: system._sys["power"] = {} system._sys["fans"] = {} system._sys["temperatures"] = {} - system._sys["firmwares"] = {} + system._sys["firmware"] = {} result = system.get_system() assert "host" in result assert "sn" in result @@ -140,7 +140,7 @@ class TestBaseRedfishSystemGetSystem: assert result["status"]["power"] == {} assert result["status"]["fans"] == {} assert result["status"]["temperatures"] == {} - assert "firmwares" in result + assert "firmware" in result class TestBaseRedfishSystemGetSpecs: @@ -248,7 +248,7 @@ class TestBaseRedfishSystemComponentSpecs: assert "power" in BaseRedfishSystem.COMPONENT_SPECS assert "fans" in BaseRedfishSystem.COMPONENT_SPECS assert "temperatures" in BaseRedfishSystem.COMPONENT_SPECS - assert "firmwares" in BaseRedfishSystem.COMPONENT_SPECS + assert "firmware" in BaseRedfishSystem.COMPONENT_SPECS def test_field_lists_non_empty(self): assert len(BaseRedfishSystem.NETWORK_FIELDS) > 0 diff --git a/src/pybind/mgr/cephadm/agent.py b/src/pybind/mgr/cephadm/agent.py index 166d9ba48c0..a7405c969a2 100644 --- a/src/pybind/mgr/cephadm/agent.py +++ b/src/pybind/mgr/cephadm/agent.py @@ -206,7 +206,7 @@ class NodeProxyEndpoint: for sys_id in data.keys(): for member in data[sys_id].keys(): member_data = data[sys_id][member] - if member == 'firmwares': + if member in ('firmware', 'firmwares'): continue _status = self._get_health_value(member_data) if _status and _status != 'ok': @@ -626,11 +626,17 @@ class NodeProxyEndpoint: @cherrypy.tools.allow(methods=['GET']) @cherrypy.tools.json_out() def firmwares(self, **kw: Any) -> Dict[str, Any]: + return self.firmware(**kw) + + @cherrypy.expose + @cherrypy.tools.allow(methods=['GET']) + @cherrypy.tools.json_out() + def firmware(self, **kw: Any) -> Dict[str, Any]: """ Handles GET request to retrieve firmware information. This function is exposed to handle GET requests and fetches firmware data using - the 'firmwares' method from the NodeProxyCache class. + the 'firmware' method from the NodeProxyCache class. :param kw: Keyword arguments for the request. :type kw: dict @@ -641,7 +647,7 @@ class NodeProxyEndpoint: :raises cherrypy.HTTPError 404: If the passed hostname is not found. """ try: - results = self.mgr.node_proxy_cache.firmwares(**kw) + results = self.mgr.node_proxy_cache.firmware(**kw) except (KeyError, OrchestratorError): raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.") return results diff --git a/src/pybind/mgr/cephadm/inventory.py b/src/pybind/mgr/cephadm/inventory.py index 3f6f75df5ac..7e396401e38 100644 --- a/src/pybind/mgr/cephadm/inventory.py +++ b/src/pybind/mgr/cephadm/inventory.py @@ -1636,6 +1636,12 @@ class NodeProxyCache: self.oob: Dict[str, Any] = {} self.keyrings: Dict[str, str] = {} + @staticmethod + def _host_firmware(host_data: Dict[str, Any]) -> Any: + if 'firmware' in host_data: + return host_data['firmware'] + return host_data.get('firmwares', {}) + def load(self) -> None: _oob = self.mgr.get_store(f'{NODE_PROXY_CACHE_PREFIX}/oob', '{}') self.oob = json.loads(_oob) @@ -1755,7 +1761,7 @@ class NodeProxyCache: _result[host]['status'][component] = state _result[host]['sn'] = data['sn'] _result[host]['host'] = data['host'] - _result[host]['status']['firmwares'] = data['firmwares'] + _result[host]['status']['firmware'] = self._host_firmware(data) return _result def common(self, endpoint: str, **kw: Any) -> Dict[str, Any]: @@ -1783,7 +1789,7 @@ class NodeProxyCache: raise KeyError(f'Invalid host {host} or component {endpoint}.') return _result - def firmwares(self, **kw: Any) -> Dict[str, Any]: + def firmware(self, **kw: Any) -> Dict[str, Any]: """ Retrieves firmware information for a specific hostname or all hosts. @@ -1798,7 +1804,7 @@ class NodeProxyCache: :rtype: Dict[str, Any] """ hosts = self._resolve_hosts(**kw) - return {host: self.data[host]['firmwares'] for host in hosts} + return {host: self._host_firmware(self.data[host]) for host in hosts} def get_critical_from_host(self, hostname: str) -> Dict[str, Any]: if hostname not in self.data: diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 3bd1bbfb73d..7edfe244d70 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -2300,9 +2300,13 @@ Then run the following: def node_proxy_summary(self, hostname: Optional[str] = None) -> Dict[str, Any]: return self.node_proxy_cache.summary(hostname=hostname) + @handle_orch_error + def node_proxy_firmware(self, hostname: Optional[str] = None) -> Dict[str, Any]: + return self.node_proxy_cache.firmware(hostname=hostname) + @handle_orch_error def node_proxy_firmwares(self, hostname: Optional[str] = None) -> Dict[str, Any]: - return self.node_proxy_cache.firmwares(hostname=hostname) + return self.node_proxy_cache.firmware(hostname=hostname) @handle_orch_error def node_proxy_criticals(self, hostname: Optional[str] = None) -> Dict[str, Any]: diff --git a/src/pybind/mgr/cephadm/tests/node_proxy_data.py b/src/pybind/mgr/cephadm/tests/node_proxy_data.py index 6f103dbeba1..3c01582d756 100644 --- a/src/pybind/mgr/cephadm/tests/node_proxy_data.py +++ b/src/pybind/mgr/cephadm/tests/node_proxy_data.py @@ -1,3 +1,3 @@ -full_set_with_critical = {'host': 'host01', 'sn': '12345', 'status': {'storage': {'1': {'disk.bay.0:enclosure.internal.0-1:raid.integrated.1-1': {'description': 'Solid State Disk 0:1:0', 'entity': 'RAID.Integrated.1-1', 'capacity_bytes': 959656755200, 'model': 'KPM5XVUG960G', 'protocol': 'SAS', 'serial_number': '8080A1CRTP5F', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 0, 'locationtype': 'Slot'}}}, 'disk.bay.9:enclosure.internal.0-1': {'description': 'PCIe SSD in Slot 9 in Bay 1', 'entity': 'CPU.1', 'capacity_bytes': 1600321314816, 'model': 'Dell Express Flash NVMe P4610 1.6TB SFF', 'protocol': 'PCIe', 'serial_number': 'PHLN035305MN1P6AGN', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 9, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 20, 'total_threads': 40, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Gold 6230 CPU @ 2.10GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'nic.slot.1-1-1': {'description': 'NIC in Slot 1 Port 1 Partition 1', 'name': 'System Ethernet Interface', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'StandbyOffline'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 31237, 'status': {'health': 'Critical', 'state': 'Enabled'}}}}}, 'firmwares': {}} +full_set_with_critical = {'host': 'host01', 'sn': '12345', 'status': {'storage': {'1': {'disk.bay.0:enclosure.internal.0-1:raid.integrated.1-1': {'description': 'Solid State Disk 0:1:0', 'entity': 'RAID.Integrated.1-1', 'capacity_bytes': 959656755200, 'model': 'KPM5XVUG960G', 'protocol': 'SAS', 'serial_number': '8080A1CRTP5F', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 0, 'locationtype': 'Slot'}}}, 'disk.bay.9:enclosure.internal.0-1': {'description': 'PCIe SSD in Slot 9 in Bay 1', 'entity': 'CPU.1', 'capacity_bytes': 1600321314816, 'model': 'Dell Express Flash NVMe P4610 1.6TB SFF', 'protocol': 'PCIe', 'serial_number': 'PHLN035305MN1P6AGN', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 9, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 20, 'total_threads': 40, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Gold 6230 CPU @ 2.10GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'nic.slot.1-1-1': {'description': 'NIC in Slot 1 Port 1 Partition 1', 'name': 'System Ethernet Interface', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'StandbyOffline'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 31237, 'status': {'health': 'Critical', 'state': 'Enabled'}}}}}, 'firmware': {}} mgr_inventory_cache = {'host01': {'hostname': 'host01', 'addr': '10.10.10.11', 'labels': ['_admin'], 'status': '', 'oob': {'hostname': '10.10.10.11', 'username': 'root', 'password': 'ceph123'}}, 'host02': {'hostname': 'host02', 'addr': '10.10.10.12', 'labels': [], 'status': '', 'oob': {'hostname': '10.10.10.12', 'username': 'root', 'password': 'ceph123'}}} -full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmwares': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} +full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} diff --git a/src/pybind/mgr/cephadm/tests/test_node_proxy.py b/src/pybind/mgr/cephadm/tests/test_node_proxy.py index dd6593fd273..ba311b19421 100644 --- a/src/pybind/mgr/cephadm/tests/test_node_proxy.py +++ b/src/pybind/mgr/cephadm/tests/test_node_proxy.py @@ -331,14 +331,18 @@ class TestNodeProxyEndpoint(helper.CPWebCase): self.getPage("/host03/temperatures", method="GET") self.assertStatus('404 Not Found') - def test_firmwares_with_valid_hostname(self): + def test_firmware_with_valid_hostname(self): + self.getPage("/host02/firmware", method="GET") + self.assertStatus('200 OK') + + def test_firmware_no_hostname(self): + self.getPage("/firmware", method="GET") + self.assertStatus('200 OK') + + def test_firmware_with_invalid_hostname(self): + self.getPage("/host03/firmware", method="GET") + self.assertStatus('404 Not Found') + + def test_firmwares_legacy_endpoint(self): self.getPage("/host02/firmwares", method="GET") self.assertStatus('200 OK') - - def test_firmwares_no_hostname(self): - self.getPage("/firmwares", method="GET") - self.assertStatus('200 OK') - - def test_firmwares_with_invalid_hostname(self): - self.getPage("/host03/firmwares", method="GET") - self.assertStatus('404 Not Found') diff --git a/src/pybind/mgr/orchestrator/_interface.py b/src/pybind/mgr/orchestrator/_interface.py index a17d20f0961..1266b84df55 100644 --- a/src/pybind/mgr/orchestrator/_interface.py +++ b/src/pybind/mgr/orchestrator/_interface.py @@ -367,9 +367,17 @@ class Orchestrator(object): """ raise NotImplementedError() + def node_proxy_firmware(self, hostname: Optional[str] = None) -> OrchResult[Dict[str, Any]]: + """ + Return node-proxy firmware report + + :param hostname: hostname + """ + raise NotImplementedError() + def node_proxy_firmwares(self, hostname: Optional[str] = None) -> OrchResult[Dict[str, Any]]: """ - Return node-proxy firmwares report + Return node-proxy firmware report (deprecated alias) :param hostname: hostname """ diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index 0930d3f267b..c6024549618 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -527,7 +527,7 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): table_heading_mapping = { 'summary': ['HOST', 'SN', 'STORAGE', 'CPU', 'NET', 'MEMORY', 'POWER', 'FANS'], 'fullreport': [], - 'firmwares': ['HOST', 'COMPONENT', 'NAME', 'DATE', 'VERSION', 'STATUS'], + 'firmware': ['HOST', 'COMPONENT', 'NAME', 'DATE', 'VERSION', 'STATUS'], 'criticals': ['HOST', 'SYS_ID', 'COMPONENT', 'NAME', 'STATUS', 'STATE'], 'memory': ['HOST', 'SYS_ID', 'NAME', 'STATUS', 'STATE'], 'storage': ['HOST', 'SYS_ID', 'NAME', 'MODEL', 'SIZE', 'PROTOCOL', 'SN', 'SLOT', 'FW', 'STATUS', 'STATE'], @@ -538,6 +538,9 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'temperatures': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], } + if category == 'firmwares': + category = 'firmware' + if category not in table_heading_mapping.keys(): return HandleCommandResult(stdout=f"'{category}' is not a valid category.") @@ -567,8 +570,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): completion = self.node_proxy_fullreport(hostname=hostname) fullreport: Dict[str, Any] = raise_if_exception(completion) output = json.dumps(fullreport) - elif category == 'firmwares': - output = 'Missing host name' if hostname is None else self._firmwares_table(hostname, table, format) + elif category == 'firmware': + output = 'Missing host name' if hostname is None else self._firmware_table(hostname, table, format) elif category == 'criticals': output = self._criticals_table(hostname, table, format) else: @@ -576,8 +579,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): return HandleCommandResult(stdout=output) - def _firmwares_table(self, hostname: Optional[str], table: PrettyTable, format: Format) -> str: - completion = self.node_proxy_firmwares(hostname=hostname) + def _firmware_table(self, hostname: Optional[str], table: PrettyTable, format: Format) -> str: + completion = self.node_proxy_firmware(hostname=hostname) data = raise_if_exception(completion) # data = self.node_proxy_firmware(hostname=hostname) if format == Format.json: From e982412a7a1f1ec6fc2fe70d11e839365b50aa91 Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Thu, 18 Jun 2026 16:54:38 +0200 Subject: [PATCH 396/596] node-proxy: expose FCM stats Collect FCM stats locally from NVMe drives (vendor log page 0xCA) and expose them via node-proxy, the cephadm agent, and `ceph orch hardware status --category fcm`. Introduce a node backend that aggregates Redfish data with node-local collectors, since FCM metrics are not available from the BMC. Fixes: https://tracker.ceph.com/issues/77521 Signed-off-by: Guillaume Abrioux --- src/ceph-node-proxy/ceph_node_proxy/api.py | 6 + .../ceph_node_proxy/atollon.py | 8 +- .../ceph_node_proxy/baseredfishsystem.py | 20 +- .../ceph_node_proxy/basesystem.py | 3 + .../ceph_node_proxy/fcm_stats.py | 284 ++++++++++++++++++ .../ceph_node_proxy/local_collectors.py | 66 ++++ src/ceph-node-proxy/ceph_node_proxy/main.py | 6 +- .../ceph_node_proxy/node_backend.py | 143 +++++++++ .../ceph_node_proxy/protocols.py | 2 + .../ceph_node_proxy/registry.py | 71 ++++- src/ceph-node-proxy/tests/test_atollon.py | 54 ++-- .../tests/test_baseredfishsystem.py | 4 + src/ceph-node-proxy/tests/test_fcm_stats.py | 196 ++++++++++++ .../tests/test_local_collectors.py | 52 ++++ .../tests/test_node_backend.py | 107 +++++++ src/pybind/mgr/cephadm/agent.py | 10 + .../mgr/cephadm/tests/node_proxy_data.py | 2 +- .../mgr/cephadm/tests/test_node_proxy.py | 12 + src/pybind/mgr/orchestrator/module.py | 5 + 19 files changed, 996 insertions(+), 55 deletions(-) create mode 100644 src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py create mode 100644 src/ceph-node-proxy/ceph_node_proxy/local_collectors.py create mode 100644 src/ceph-node-proxy/ceph_node_proxy/node_backend.py create mode 100644 src/ceph-node-proxy/tests/test_fcm_stats.py create mode 100644 src/ceph-node-proxy/tests/test_local_collectors.py create mode 100644 src/ceph-node-proxy/tests/test_node_backend.py diff --git a/src/ceph-node-proxy/ceph_node_proxy/api.py b/src/ceph-node-proxy/ceph_node_proxy/api.py index 563ac88c8f7..7df31540afd 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/api.py +++ b/src/ceph-node-proxy/ceph_node_proxy/api.py @@ -98,6 +98,12 @@ class API(Server): def storage(self) -> Dict[str, Any]: return {"storage": self.backend.get_storage()} + @cherrypy.expose + @cherrypy.tools.allow(methods=["GET"]) + @cherrypy.tools.json_out() + def fcm(self) -> Dict[str, Any]: + return {"fcm": self.backend.get_fcm()} + @cherrypy.expose @cherrypy.tools.allow(methods=["GET"]) @cherrypy.tools.json_out() diff --git a/src/ceph-node-proxy/ceph_node_proxy/atollon.py b/src/ceph-node-proxy/ceph_node_proxy/atollon.py index 32aa249b4be..c440535ffb2 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/atollon.py +++ b/src/ceph-node-proxy/ceph_node_proxy/atollon.py @@ -7,7 +7,9 @@ from ceph_node_proxy.util import get_logger INVALID_SERIALS = ("unknown", "not available", "n/a", "") -class AtollonSystem(BaseRedfishSystem): +class AtollonRedfishProvider(BaseRedfishSystem): + """Redfish provider for Atollon BMC quirks.""" + def __init__(self, **kw: Any) -> None: super().__init__(**kw) self.log = get_logger(__name__) @@ -124,3 +126,7 @@ class AtollonSystem(BaseRedfishSystem): speed_gbps = controller.get("SpeedGbps") if speed_gbps is not None: drive_data["speed_gbps"] = speed_gbps + + +# Backward-compatible alias for entry points and external imports. +AtollonSystem = AtollonRedfishProvider diff --git a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py index 3da5a0f9398..3d9b0ab97e8 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/baseredfishsystem.py @@ -139,6 +139,16 @@ class BaseRedfishSystem(BaseSystem): "refresh_interval", DEFAULTS["system"]["refresh_interval"] ) + def initialize_redfish_session(self) -> None: + self.client.login() + self.endpoints.init() + + def run_update_cycle(self) -> None: + self._update_system() + self._update_sn() + with concurrent.futures.ThreadPoolExecutor() as executor: + executor.map(lambda f: f(), self.update_funcs) + def update( self, collection: str, @@ -160,8 +170,7 @@ class BaseRedfishSystem(BaseSystem): def main(self) -> None: self.stop = False - self.client.login() - self.endpoints.init() + self.initialize_redfish_session() while not self.stop: self.log.debug("waiting for a lock in the update loop.") @@ -169,12 +178,7 @@ class BaseRedfishSystem(BaseSystem): if not self.pending_shutdown: self.log.debug("lock acquired in the update loop.") try: - self._update_system() - self._update_sn() - - with concurrent.futures.ThreadPoolExecutor() as executor: - executor.map(lambda f: f(), self.update_funcs) - + self.run_update_cycle() self.data_ready = True except RuntimeError as e: self.stop = True diff --git a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py index aecc0e137c0..405b9d3c1d9 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/basesystem.py +++ b/src/ceph-node-proxy/ceph_node_proxy/basesystem.py @@ -48,6 +48,9 @@ class BaseSystem(BaseThread): def get_storage(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() + def get_fcm(self) -> Dict[str, Dict[str, Dict]]: + return {} + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: raise NotImplementedError() diff --git a/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py b/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py new file mode 100644 index 00000000000..9e235760ba9 --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/fcm_stats.py @@ -0,0 +1,284 @@ +import ctypes +import fcntl +import glob +import os +import struct +from typing import Optional, TypedDict + +from ceph_node_proxy.util import get_logger + +SYSFS_BLOCK = "/sys/block" + +NVME_ADMIN_OPC_GET_LOG_PAGE = 0x02 +NVME_NSID_ALL = 0xFFFFFFFF +FCM_LOG_PAGE_ID = 0xCA +FCM_LOG_PAGE_LEN = 32 +FCM_LOG_PAGE_OFFSET = 280 +FCM_RATIO_DISPLAY_MIN_LOG_UTIL_PERCENT = 0.05 + +_IOC_NRBITS = 8 +_IOC_TYPEBITS = 8 +_IOC_SIZEBITS = 14 +_IOC_DIRBITS = 2 +_IOC_NRSHIFT = 0 +_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS +_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS +_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS + + +def ioc(dir_: int, type_: str, nr: int, size: int) -> int: + return ( + (dir_ << _IOC_DIRSHIFT) + | (ord(type_) << _IOC_TYPESHIFT) + | (nr << _IOC_NRSHIFT) + | (size << _IOC_SIZESHIFT) + ) + + +class _NvmePassthruCmd64(ctypes.Structure): + _fields_ = [ + ("opcode", ctypes.c_uint8), + ("flags", ctypes.c_uint8), + ("rsvd1", ctypes.c_uint16), + ("nsid", ctypes.c_uint32), + ("cdw2", ctypes.c_uint32), + ("cdw3", ctypes.c_uint32), + ("metadata", ctypes.c_uint64), + ("addr", ctypes.c_uint64), + ("metadata_len", ctypes.c_uint32), + ("data_len", ctypes.c_uint32), + ("cdw10", ctypes.c_uint32), + ("cdw11", ctypes.c_uint32), + ("cdw12", ctypes.c_uint32), + ("cdw13", ctypes.c_uint32), + ("cdw14", ctypes.c_uint32), + ("cdw15", ctypes.c_uint32), + ("timeout_ms", ctypes.c_uint32), + ("rsvd2", ctypes.c_uint32), + ("result", ctypes.c_uint64), + ] + + +NVME_IOCTL_ADMIN64_CMD = ioc( + 3, "N", 0x47, ctypes.sizeof(_NvmePassthruCmd64) +) + +logger = get_logger(__name__) + + +class FCMStatsData(TypedDict): + device: str + model: str + serial_number: str + valid: bool + phy_size_bytes: int + phy_util_bytes: int + log_size_bytes: int + log_util_bytes: int + phy_util_percent: float + log_util_percent: float + compression_ratio: float + compression_ratio_str: str + savings_bytes: int + compression_ratio_display: str + savings_display: str + phy_usage_display: str + log_usage_display: str + status: dict[str, str] + + +def read_sysfs(path: str) -> str: + try: + with open(path, encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return "" + + +def read_sysfs_block(device: str, attribute: str) -> str: + return read_sysfs(os.path.join(SYSFS_BLOCK, device, attribute)) + + +def list_nvme_namespace_names() -> list[str]: + return sorted( + os.path.basename(path) + for path in glob.glob(f"{SYSFS_BLOCK}/nvme*") + if "c" not in os.path.basename(path) + ) + + +def is_fcm_device(device: str) -> bool: + model = read_sysfs_block(device, "device/model") + return "FCM" in model + + +def query_nvme_log_page(device: str) -> Optional[bytes]: + device_path = f"/dev/{device}" + numd = (FCM_LOG_PAGE_LEN // 4) - 1 + numdl = numd & 0xFFFF + numdu = (numd >> 16) & 0xFFFF + + buffer = (ctypes.c_uint8 * FCM_LOG_PAGE_LEN)() + command = _NvmePassthruCmd64() + command.opcode = NVME_ADMIN_OPC_GET_LOG_PAGE + command.nsid = NVME_NSID_ALL + command.addr = ctypes.addressof(buffer) + command.data_len = FCM_LOG_PAGE_LEN + command.cdw10 = FCM_LOG_PAGE_ID | (numdl << 16) + command.cdw11 = numdu + command.cdw12 = FCM_LOG_PAGE_OFFSET & 0xFFFFFFFF + command.cdw13 = (FCM_LOG_PAGE_OFFSET >> 32) & 0xFFFFFFFF + + fd = os.open(device_path, os.O_RDONLY) + try: + fcntl.ioctl(fd, NVME_IOCTL_ADMIN64_CMD, command) + except OSError as exc: + logger.debug( + "NVMe log page 0xCA query failed for device %s: %s", + device, + exc, + ) + return None + finally: + os.close(fd) + + if command.result: + logger.debug( + "NVMe log page 0xCA returned non-zero status 0x%x for device %s", + command.result, + device, + ) + return None + + return bytes(buffer) + + +def compression_ratio_str(ratio: float) -> str: + ratio_text = f"{ratio:.1f}" + if ratio_text.endswith("0"): + return f"{int(ratio)}:1" + return f"{ratio_text}:1" + + +def human_readable(capacity: int, dec_places: int = 1) -> str: + suffixes = ["b", "KB", "MB", "GB", "TB", "PB"] + size = float(capacity) + unit = suffixes[0] + for unit in suffixes: + if size < 1000: + break + size /= 1000 + return f"{size:.{dec_places}f} {unit}" + + +def fcm_usage_display(util_bytes: int, util_percent: float) -> str: + return f"{human_readable(util_bytes)}({int(util_percent)}%)" + + +def empty_fcm_display_fields() -> dict[str, str]: + return { + "compression_ratio_display": "", + "savings_display": "", + "phy_usage_display": "", + "log_usage_display": "", + } + + +def apply_fcm_display_fields(stats: FCMStatsData) -> FCMStatsData: + display = empty_fcm_display_fields() + if not stats["valid"]: + return {**stats, **display} + + display["savings_display"] = human_readable(stats["savings_bytes"]) + display["phy_usage_display"] = fcm_usage_display( + stats["phy_util_bytes"], stats["phy_util_percent"] + ) + display["log_usage_display"] = fcm_usage_display( + stats["log_util_bytes"], stats["log_util_percent"] + ) + if stats["log_util_percent"] > FCM_RATIO_DISPLAY_MIN_LOG_UTIL_PERCENT: + display["compression_ratio_display"] = stats["compression_ratio_str"] + return {**stats, **display} + + +def read_fcm_stats(device: str) -> FCMStatsData: + model = read_sysfs_block(device, "device/model") + serial_number = read_sysfs_block(device, "device/serial") + invalid = apply_fcm_display_fields({ + "device": device, + "model": model, + "serial_number": serial_number, + "valid": False, + "phy_size_bytes": 0, + "phy_util_bytes": 0, + "log_size_bytes": 0, + "log_util_bytes": 0, + "phy_util_percent": 0.0, + "log_util_percent": 0.0, + "compression_ratio": 0.0, + "compression_ratio_str": "", + "savings_bytes": 0, + "status": {"health": "Unknown", "state": "Unavailable"}, + "compression_ratio_display": "", + "savings_display": "", + "phy_usage_display": "", + "log_usage_display": "", + }) + + raw_bytes = query_nvme_log_page(device) + if raw_bytes is None: + return invalid + + try: + phy_size_bytes = struct.unpack(" dict[str, FCMStatsData]: + stats: dict[str, FCMStatsData] = {} + for device in list_nvme_namespace_names(): + if not is_fcm_device(device): + continue + stats[device] = read_fcm_stats(device) + return stats diff --git a/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py b/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py new file mode 100644 index 00000000000..1be1b231544 --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/local_collectors.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + +from ceph_node_proxy.fcm_stats import collect_fcm_stats + + +class LocalCollector(ABC): + """Collects node-local hardware metrics outside of Redfish.""" + + @property + @abstractmethod + def name(self) -> str: + """Category name exposed in node-proxy reports.""" + + @abstractmethod + def update(self) -> None: + """Refresh collector data from the local OS.""" + + @abstractmethod + def get_data(self) -> Dict[str, Any]: + """Return the latest collected data.""" + + def flush(self) -> None: + """Clear cached collector data.""" + + +class LocalCollectorRunner: + def __init__(self, collectors: list[LocalCollector]) -> None: + self._collectors = collectors + + def update(self) -> None: + for collector in self._collectors: + collector.update() + + def flush(self) -> None: + for collector in self._collectors: + collector.flush() + + def categories(self) -> list[str]: + return [collector.name for collector in self._collectors] + + def get_category(self, name: str) -> Dict[str, Any]: + for collector in self._collectors: + if collector.name == name: + return collector.get_data() + return {} + + +class FCMCollector(LocalCollector): + """Collect FCM stats via NVMe ioctls.""" + + def __init__(self) -> None: + self._data: Dict[str, Any] = {} + + @property + def name(self) -> str: + return "fcm" + + def update(self) -> None: + self._data = {"local": collect_fcm_stats()} + + def get_data(self) -> Dict[str, Any]: + return dict(self._data) + + def flush(self) -> None: + self._data = {} diff --git a/src/ceph-node-proxy/ceph_node_proxy/main.py b/src/ceph-node-proxy/ceph_node_proxy/main.py index 6f0ab0b593d..775626af216 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/main.py +++ b/src/ceph-node-proxy/ceph_node_proxy/main.py @@ -11,7 +11,7 @@ from urllib.error import HTTPError from ceph_node_proxy.api import NodeProxyApi from ceph_node_proxy.bootstrap import create_node_proxy_manager from ceph_node_proxy.config import load_cephadm_config -from ceph_node_proxy.registry import get_system_class +from ceph_node_proxy.registry import create_node_backend from ceph_node_proxy.reporter import Reporter from ceph_node_proxy.util import DEFAULTS, Config, get_logger, http_req @@ -105,8 +105,8 @@ class NodeProxyManager: try: vendor = self.config.get("system", {}).get("vendor", "generic") self.log.info("Using Redfish vendor: %s", vendor) - system_cls = get_system_class(vendor) - self.system = system_cls( + self.system = create_node_backend( + vendor, host=oob_details["host"], port=oob_details["port"], username=oob_details["username"], diff --git a/src/ceph-node-proxy/ceph_node_proxy/node_backend.py b/src/ceph-node-proxy/ceph_node_proxy/node_backend.py new file mode 100644 index 00000000000..e7549e5913e --- /dev/null +++ b/src/ceph-node-proxy/ceph_node_proxy/node_backend.py @@ -0,0 +1,143 @@ +from time import sleep +from typing import Any, Dict + +from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem +from ceph_node_proxy.basesystem import BaseSystem +from ceph_node_proxy.local_collectors import LocalCollectorRunner +from ceph_node_proxy.util import get_logger + + +class NodeBackend(BaseSystem): + def __init__( + self, + redfish: BaseRedfishSystem, + local_collectors: LocalCollectorRunner, + ) -> None: + super().__init__(config=redfish.config) + self.redfish = redfish + self._local = local_collectors + self.data_ready = False + self.previous_data: Dict[str, Any] = {} + self.log = get_logger(__name__) + + @property + def client(self) -> Any: + return self.redfish.client + + def main(self) -> None: + self.stop = False + self.redfish.stop = False + self.redfish.initialize_redfish_session() + + while not self.stop: + self.log.debug("waiting for a lock in the update loop.") + with self.lock: + if not self.pending_shutdown: + self.log.debug("lock acquired in the update loop.") + try: + self.redfish.run_update_cycle() + self._local.update() + self.data_ready = True + except RuntimeError as exc: + self.stop = True + self.redfish.stop = True + self.log.error( + "Error detected, trying to gracefully log out from " + "redfish api.\n%s", + exc, + ) + self.redfish.client.logout() + raise + sleep(self.redfish.refresh_interval) + self.log.debug("lock released in the update loop.") + self.log.debug("exiting update loop.") + raise SystemExit(0) + + def flush(self) -> None: + self.log.debug("Acquiring lock to flush data.") + self.lock.acquire() + self.log.debug("Lock acquired, flushing data.") + self.redfish.flush() + self._local.flush() + self.previous_data = {} + self.data_ready = False + self.log.info("Data flushed.") + self.lock.release() + + def get_system(self) -> Dict[str, Any]: + result = self.redfish.get_system() + for category in self._local.categories(): + result["status"][category] = self._local.get_category(category) + return result + + def get_local_category(self, name: str) -> Dict[str, Any]: + return self._local.get_category(name) + + def get_fcm(self) -> Dict[str, Any]: + return self.get_local_category("fcm") + + def get_status(self) -> Dict[str, Dict[str, Dict]]: + return dict(self.redfish.get_status()) + + def get_memory(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_memory() + + def get_processors(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_processors() + + def get_network(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_network() + + def get_storage(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_storage() + + def get_firmware(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_firmware() + + def get_power(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_power() + + def get_fans(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_fans() + + def get_temperatures(self) -> Dict[str, Dict[str, Dict]]: + return self.redfish.get_temperatures() + + def get_sn(self) -> str: + return self.redfish.get_sn() + + def get_led(self) -> Dict[str, Any]: + return self.redfish.get_led() + + def set_led(self, data: Dict[str, str]) -> int: + return self.redfish.set_led(data) + + def get_chassis_led(self) -> Dict[str, Any]: + return self.redfish.get_chassis_led() + + def set_chassis_led(self, data: Dict[str, str]) -> int: + return self.redfish.set_chassis_led(data) + + def device_led_on(self, device: str) -> int: + return self.redfish.device_led_on(device) + + def device_led_off(self, device: str) -> int: + return self.redfish.device_led_off(device) + + def get_device_led(self, device: str) -> Dict[str, Any]: + return self.redfish.get_device_led(device) + + def set_device_led(self, device: str, data: Dict[str, bool]) -> int: + return self.redfish.set_device_led(device, data) + + def chassis_led_on(self) -> int: + return self.redfish.chassis_led_on() + + def chassis_led_off(self) -> int: + return self.redfish.chassis_led_off() + + def shutdown_host(self, force: bool = False) -> int: + return self.redfish.shutdown_host(force=force) + + def powercycle(self) -> int: + return self.redfish.powercycle() diff --git a/src/ceph-node-proxy/ceph_node_proxy/protocols.py b/src/ceph-node-proxy/ceph_node_proxy/protocols.py index e82d425bea5..85f76b48360 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/protocols.py +++ b/src/ceph-node-proxy/ceph_node_proxy/protocols.py @@ -10,6 +10,8 @@ class SystemBackend(Protocol): def get_storage(self) -> Dict[str, Any]: ... + def get_fcm(self) -> Dict[str, Any]: ... + def get_processors(self) -> Dict[str, Any]: ... def get_power(self) -> Dict[str, Any]: ... diff --git a/src/ceph-node-proxy/ceph_node_proxy/registry.py b/src/ceph-node-proxy/ceph_node_proxy/registry.py index 6c9436bd2f9..22eecb64658 100644 --- a/src/ceph-node-proxy/ceph_node_proxy/registry.py +++ b/src/ceph-node-proxy/ceph_node_proxy/registry.py @@ -1,43 +1,84 @@ -from typing import Dict, Type +from typing import Dict, List, Type -# Built-in implementations -from ceph_node_proxy.atollon import AtollonSystem +from ceph_node_proxy.atollon import AtollonRedfishProvider from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem +from ceph_node_proxy.local_collectors import FCMCollector, LocalCollector, LocalCollectorRunner +from ceph_node_proxy.node_backend import NodeBackend from ceph_node_proxy.redfishdellsystem import RedfishDellSystem -from ceph_node_proxy.util import get_logger +from ceph_node_proxy.util import Config, get_logger -REDFISH_SYSTEM_CLASSES: Dict[str, Type[BaseRedfishSystem]] = { +REDFISH_PROVIDER_CLASSES: Dict[str, Type[BaseRedfishSystem]] = { "generic": BaseRedfishSystem, "dell": RedfishDellSystem, - "atollon": AtollonSystem, + "atollon": AtollonRedfishProvider, +} + +LOCAL_COLLECTOR_CLASSES_BY_VENDOR: Dict[str, List[Type[LocalCollector]]] = { + "atollon": [FCMCollector], } logger = get_logger(__name__) -def _load_entry_point_systems() -> None: +def _load_entry_point_redfish_providers() -> None: try: import pkg_resources # type: ignore[import-not-found] except ImportError: logger.debug( - "pkg_resources not available; only built-in Redfish systems will be used." + "pkg_resources not available; only built-in Redfish providers will be used." ) return for ep in pkg_resources.iter_entry_points("ceph_node_proxy.systems"): try: - REDFISH_SYSTEM_CLASSES[ep.name] = ep.load() - except (ImportError, AttributeError, ModuleNotFoundError) as e: + REDFISH_PROVIDER_CLASSES[ep.name] = ep.load() + except (ImportError, AttributeError, ModuleNotFoundError) as exc: logger.warning( - "Failed to load Redfish system entry point %s: %s", ep.name, e + "Failed to load Redfish provider entry point %s: %s", ep.name, exc ) +def get_redfish_provider_class(vendor: str) -> Type[BaseRedfishSystem]: + """Return the Redfish provider class for the given vendor.""" + return REDFISH_PROVIDER_CLASSES.get(vendor, BaseRedfishSystem) + + def get_system_class(vendor: str) -> Type[BaseRedfishSystem]: - """Return the Redfish system class for the given vendor. - Falls back to generic.""" - return REDFISH_SYSTEM_CLASSES.get(vendor, BaseRedfishSystem) + """Deprecated alias for get_redfish_provider_class.""" + return get_redfish_provider_class(vendor) -_load_entry_point_systems() +def create_local_collector_runner(vendor: str) -> LocalCollectorRunner: + """Build the local collector runner configured for a vendor.""" + collector_classes = LOCAL_COLLECTOR_CLASSES_BY_VENDOR.get(vendor, []) + collectors = [collector_cls() for collector_cls in collector_classes] + return LocalCollectorRunner(collectors) + + +def create_node_backend( + vendor: str, + *, + host: str, + port: str, + username: str, + password: str, + config: Config, +) -> NodeBackend: + """Create the node backend facade for a vendor.""" + provider_cls = get_redfish_provider_class(vendor) + redfish = provider_cls( + host=host, + port=port, + username=username, + password=password, + config=config, + ) + local_collectors = create_local_collector_runner(vendor) + return NodeBackend(redfish, local_collectors) + + +_load_entry_point_redfish_providers() + +# Backward-compatible alias used by older imports and entry point docs. +REDFISH_SYSTEM_CLASSES = REDFISH_PROVIDER_CLASSES diff --git a/src/ceph-node-proxy/tests/test_atollon.py b/src/ceph-node-proxy/tests/test_atollon.py index 7c4b9a179e1..c2b1fea3df4 100644 --- a/src/ceph-node-proxy/tests/test_atollon.py +++ b/src/ceph-node-proxy/tests/test_atollon.py @@ -2,17 +2,17 @@ from unittest.mock import MagicMock, patch import pytest -from ceph_node_proxy.atollon import AtollonSystem +from ceph_node_proxy.atollon import AtollonRedfishProvider from ceph_node_proxy.baseredfishsystem import BaseRedfishSystem @pytest.fixture -def atollon_system(): +def atollon_redfish(): with ( patch("ceph_node_proxy.baseredfishsystem.RedFishClient", return_value=MagicMock()), patch("ceph_node_proxy.baseredfishsystem.EndpointMgr", return_value=MagicMock()), ): - return AtollonSystem( + return AtollonRedfishProvider( host="testhost", port="443", username="user", @@ -21,16 +21,16 @@ def atollon_system(): ) -class TestAtollonSystemMemoryOverrides: - def test_get_specs_memory_uses_id_instead_of_description(self, atollon_system): - specs = atollon_system.get_specs("memory") +class TestAtollonRedfishProviderMemoryOverrides: + def test_get_specs_memory_uses_id_instead_of_description(self, atollon_redfish): + specs = atollon_redfish.get_specs("memory") assert len(specs) == 1 assert "Id" in specs[0].fields assert "Description" not in specs[0].fields assert "MemoryDeviceType" in specs[0].fields - def test_update_memory_maps_id_to_description(self, atollon_system): - atollon_system._sys["memory"] = { + def test_update_memory_maps_id_to_description(self, atollon_redfish): + atollon_redfish._sys["memory"] = { "1": { "dimm0": { "id": "dimm0", @@ -43,14 +43,14 @@ class TestAtollonSystemMemoryOverrides: with patch.object(BaseRedfishSystem, "_update_memory") as mock_super: mock_super.side_effect = lambda: None - atollon_system._update_memory() + atollon_redfish._update_memory() - assert atollon_system._sys["memory"]["1"]["dimm0"]["description"] == "dimm0" + assert atollon_redfish._sys["memory"]["1"]["dimm0"]["description"] == "dimm0" -class TestAtollonSystemStorageOverrides: - def test_update_storage_replaces_unknown_description(self, atollon_system): - atollon_system._sys["storage"] = { +class TestAtollonRedfishProviderStorageOverrides: + def test_update_storage_replaces_unknown_description(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device0_nsid1": { "description": "unknown", @@ -70,13 +70,13 @@ class TestAtollonSystemStorageOverrides: with patch.object(BaseRedfishSystem, "_update_storage") as mock_super: mock_super.side_effect = lambda: None - atollon_system.fix_storage_descriptions() + atollon_redfish.fix_storage_descriptions() - drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device0_nsid1"] assert drive["description"] == "NVMe_Device0_NSID1" - def test_enrich_storage_from_controllers_by_serial(self, atollon_system): - atollon_system._sys["storage"] = { + def test_enrich_storage_from_controllers_by_serial(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device0_nsid1": { "description": "NVMe_Device0_NSID1", @@ -109,21 +109,21 @@ class TestAtollonSystemStorageOverrides: systems_endpoint.__getitem__ = MagicMock( side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() ) - atollon_system.endpoints = MagicMock() - atollon_system.endpoints.__getitem__ = MagicMock( + atollon_redfish.endpoints = MagicMock() + atollon_redfish.endpoints.__getitem__ = MagicMock( side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() ) - atollon_system.enrich_storage_from_controllers() + atollon_redfish.enrich_storage_from_controllers() - drive = atollon_system._sys["storage"]["Self"]["nvme_device0_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device0_nsid1"] assert drive["firmware_version"] == "V6MA001" assert drive["slot"] == "0" assert drive["speed_gbps"] == 63.02 assert drive["physical_location"]["partlocation"]["locationordinalvalue"] == 0 - def test_enrich_storage_from_controllers_by_device_index(self, atollon_system): - atollon_system._sys["storage"] = { + def test_enrich_storage_from_controllers_by_device_index(self, atollon_redfish): + atollon_redfish._sys["storage"] = { "Self": { "nvme_device2_nsid1": { "description": "NVMe_Device2_NSID1", @@ -153,13 +153,13 @@ class TestAtollonSystemStorageOverrides: systems_endpoint.__getitem__ = MagicMock( side_effect=lambda key: member_endpoint if key == "Self" else MagicMock() ) - atollon_system.endpoints = MagicMock() - atollon_system.endpoints.__getitem__ = MagicMock( + atollon_redfish.endpoints = MagicMock() + atollon_redfish.endpoints.__getitem__ = MagicMock( side_effect=lambda key: systems_endpoint if key == "systems" else MagicMock() ) - atollon_system.enrich_storage_from_controllers() + atollon_redfish.enrich_storage_from_controllers() - drive = atollon_system._sys["storage"]["Self"]["nvme_device2_nsid1"] + drive = atollon_redfish._sys["storage"]["Self"]["nvme_device2_nsid1"] assert drive["firmware_version"] == "000A5305" assert drive["slot"] == "2" diff --git a/src/ceph-node-proxy/tests/test_baseredfishsystem.py b/src/ceph-node-proxy/tests/test_baseredfishsystem.py index 28a90521406..05102ca1445 100644 --- a/src/ceph-node-proxy/tests/test_baseredfishsystem.py +++ b/src/ceph-node-proxy/tests/test_baseredfishsystem.py @@ -140,8 +140,12 @@ class TestBaseRedfishSystemGetSystem: assert result["status"]["power"] == {} assert result["status"]["fans"] == {} assert result["status"]["temperatures"] == {} + assert "fcm" not in result["status"] assert "firmware" in result + def test_get_fcm_empty(self, system): + assert system.get_fcm() == {} + class TestBaseRedfishSystemGetSpecs: diff --git a/src/ceph-node-proxy/tests/test_fcm_stats.py b/src/ceph-node-proxy/tests/test_fcm_stats.py new file mode 100644 index 00000000000..686cb948de8 --- /dev/null +++ b/src/ceph-node-proxy/tests/test_fcm_stats.py @@ -0,0 +1,196 @@ +import struct +from unittest.mock import patch + +from ceph_node_proxy.fcm_stats import ( + apply_fcm_display_fields, + collect_fcm_stats, + fcm_usage_display, + is_fcm_device, + read_fcm_stats, +) + + +class TestFCMStatsHelpers: + def test_is_fcm_device_true(self): + with patch( + "ceph_node_proxy.fcm_stats.read_sysfs_block", + return_value="IBM FCM5 3.2TB", + ): + assert is_fcm_device("nvme2n1") is True + + def test_is_fcm_device_false(self): + with patch( + "ceph_node_proxy.fcm_stats.read_sysfs_block", + return_value="Micron_2550_MTFDKBK512TGE", + ): + assert is_fcm_device("nvme0n1") is False + + +class TestReadFCMStats: + def _sample_log_page(self) -> bytes: + return struct.pack( + " Dict[str, Any]: + try: + results = self.mgr.node_proxy_cache.common('fcm', **kw) + except (KeyError, OrchestratorError): + raise cherrypy.HTTPError(404, f"{kw.get('hostname')} not found.") + return results + @cherrypy.expose @cherrypy.tools.allow(methods=['GET']) @cherrypy.tools.json_out() diff --git a/src/pybind/mgr/cephadm/tests/node_proxy_data.py b/src/pybind/mgr/cephadm/tests/node_proxy_data.py index 3c01582d756..8e4230fe15f 100644 --- a/src/pybind/mgr/cephadm/tests/node_proxy_data.py +++ b/src/pybind/mgr/cephadm/tests/node_proxy_data.py @@ -1,3 +1,3 @@ full_set_with_critical = {'host': 'host01', 'sn': '12345', 'status': {'storage': {'1': {'disk.bay.0:enclosure.internal.0-1:raid.integrated.1-1': {'description': 'Solid State Disk 0:1:0', 'entity': 'RAID.Integrated.1-1', 'capacity_bytes': 959656755200, 'model': 'KPM5XVUG960G', 'protocol': 'SAS', 'serial_number': '8080A1CRTP5F', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 0, 'locationtype': 'Slot'}}}, 'disk.bay.9:enclosure.internal.0-1': {'description': 'PCIe SSD in Slot 9 in Bay 1', 'entity': 'CPU.1', 'capacity_bytes': 1600321314816, 'model': 'Dell Express Flash NVMe P4610 1.6TB SFF', 'protocol': 'PCIe', 'serial_number': 'PHLN035305MN1P6AGN', 'status': {'health': 'Critical', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 9, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 20, 'total_threads': 40, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Gold 6230 CPU @ 2.10GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'nic.slot.1-1-1': {'description': 'NIC in Slot 1 Port 1 Partition 1', 'name': 'System Ethernet Interface', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'StandbyOffline'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 31237, 'status': {'health': 'Critical', 'state': 'Enabled'}}}}}, 'firmware': {}} mgr_inventory_cache = {'host01': {'hostname': 'host01', 'addr': '10.10.10.11', 'labels': ['_admin'], 'status': '', 'oob': {'hostname': '10.10.10.11', 'username': 'root', 'password': 'ceph123'}}, 'host02': {'hostname': 'host02', 'addr': '10.10.10.12', 'labels': [], 'status': '', 'oob': {'hostname': '10.10.10.12', 'username': 'root', 'password': 'ceph123'}}} -full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} +full_set = {'host01': {'host': 'host01', 'sn': 'FR8Y5X3', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fcm': {'local': {'nvme2n1': {'device': 'nvme2n1', 'model': 'IBM FCM5 3.2TB', 'serial_number': '03NK797YS344D57S056', 'valid': True, 'compression_ratio': 2.5, 'compression_ratio_str': '2.5:1', 'savings_bytes': 1000000, 'phy_util_percent': 50.0, 'log_util_percent': 25.0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'host02': {'host': 'host02', 'sn': 'FR8Y5X4', 'status': {'storage': {'1': {'disk.bay.8:enclosure.internal.0-1:nonraid.slot.2-1': {'description': 'Disk 8 in Backplane 1 of Storage Controller in Slot 2', 'entity': 'NonRAID.Slot.2-1', 'capacity_bytes': 20000588955136, 'model': 'ST20000NM008D-3D', 'protocol': 'SATA', 'serial_number': 'ZVT99QLL', 'status': {'health': 'OK', 'healthrollup': 'OK', 'state': 'Enabled'}, 'physical_location': {'partlocation': {'locationordinalvalue': 8, 'locationtype': 'Slot'}}}}}, 'processors': {'1': {'cpu.socket.2': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}, 'cpu.socket.1': {'description': 'Represents the properties of a Processor attached to this System', 'total_cores': 16, 'total_threads': 32, 'processor_type': 'CPU', 'model': 'Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz', 'status': {'health': 'OK', 'state': 'Enabled'}, 'manufacturer': 'Intel'}}}, 'network': {'1': {'oslogicalnetwork.2': {'description': 'eno8303', 'name': 'eno8303', 'speed_mbps': 0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'memory': {'1': {'dimm.socket.a1': {'description': 'DIMM A1', 'memory_device_type': 'DDR4', 'capacity_mi_b': 16384, 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'power': {'1': {'0': {'name': 'PS1 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}, '1': {'name': 'PS2 Status', 'model': 'PWR SPLY,800W,RDNT,LTON', 'manufacturer': 'DELL', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fans': {'1': {'0': {'name': 'System Board Fan1A', 'physical_context': 'SystemBoard', 'reading': 4800, 'reading_units': 'RPM', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'temperatures': {'1': {'0': {'name': 'System Board Inlet Temp', 'physical_context': 'Intake', 'reading': 24, 'reading_units': 'Cel', 'status': {'health': 'OK', 'state': 'Enabled'}}}}, 'fcm': {'local': {'nvme2n1': {'device': 'nvme2n1', 'model': 'IBM FCM5 3.2TB', 'serial_number': '03NK797YS344D57S056', 'valid': True, 'compression_ratio': 2.5, 'compression_ratio_str': '2.5:1', 'savings_bytes': 1000000, 'phy_util_percent': 50.0, 'log_util_percent': 25.0, 'status': {'health': 'OK', 'state': 'Enabled'}}}}}, 'firmware': {'installed-28897-6.10.30.20__usc.embedded.1:lc.embedded.1': {'name': 'Lifecycle Controller', 'description': 'Represents Firmware Inventory', 'release_date': '00:00:00Z', 'version': '6.10.30.20', 'updateable': True, 'status': {'health': 'OK', 'state': 'Enabled'}}}}} diff --git a/src/pybind/mgr/cephadm/tests/test_node_proxy.py b/src/pybind/mgr/cephadm/tests/test_node_proxy.py index ba311b19421..e053e10e920 100644 --- a/src/pybind/mgr/cephadm/tests/test_node_proxy.py +++ b/src/pybind/mgr/cephadm/tests/test_node_proxy.py @@ -331,6 +331,18 @@ class TestNodeProxyEndpoint(helper.CPWebCase): self.getPage("/host03/temperatures", method="GET") self.assertStatus('404 Not Found') + def test_fcm_with_valid_hostname(self): + self.getPage("/host02/fcm", method="GET") + self.assertStatus('200 OK') + + def test_fcm_no_hostname(self): + self.getPage("/fcm", method="GET") + self.assertStatus('200 OK') + + def test_fcm_with_invalid_hostname(self): + self.getPage("/host03/fcm", method="GET") + self.assertStatus('404 Not Found') + def test_firmware_with_valid_hostname(self): self.getPage("/host02/firmware", method="GET") self.assertStatus('200 OK') diff --git a/src/pybind/mgr/orchestrator/module.py b/src/pybind/mgr/orchestrator/module.py index c6024549618..f9d5a4b9237 100644 --- a/src/pybind/mgr/orchestrator/module.py +++ b/src/pybind/mgr/orchestrator/module.py @@ -536,6 +536,8 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'power': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'MODEL', 'MANUFACTURER', 'STATUS', 'STATE'], 'fans': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], 'temperatures': ['HOST', 'CHASSIS_ID', 'ID', 'NAME', 'READING', 'UNITS', 'STATUS', 'STATE'], + 'fcm': ['HOST', 'SOURCE', 'DEVICE', 'MODEL', 'SN', 'RATIO', 'SAVINGS', + 'PHYS USED', 'LOG USED', 'VALID', 'STATUS', 'STATE'], } if category == 'firmwares': @@ -625,6 +627,9 @@ class OrchestratorCli(OrchestratorClientMixin, MgrModule): 'power': ('name', 'model', 'manufacturer', 'health', 'state'), 'fans': ('name', 'reading', 'reading_units', 'health', 'state'), 'temperatures': ('name', 'reading', 'reading_units', 'health', 'state'), + 'fcm': ('device', 'model', 'serial_number', 'compression_ratio_display', + 'savings_display', 'phy_usage_display', 'log_usage_display', + 'valid', 'health', 'state'), } fields = mapping.get(category, ()) From 3a9a837ef7c220aa04ab0f4d124f5a2d372a0bac Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 23 Jun 2026 18:18:25 +0800 Subject: [PATCH 397/596] test/bufferlist: avoid BenchDeref iterator overrun past end Drive the deref loop by get_remaining() >= step instead of comparing to end(), so a total length that is not a multiple of step no longer lets `iter += step` run past the end and throw "End of buffer". The original counts were always a multiple of step, so the overrun only showed up once the following commit shrinks the rounds under ASan -- a false alarm rather than a real regression, which is why it went unnoticed until now. Signed-off-by: Sun Yuechi --- src/test/bufferlist.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/bufferlist.cc b/src/test/bufferlist.cc index ba83f532934..41b47c61029 100644 --- a/src/test/bufferlist.cc +++ b/src/test/bufferlist.cc @@ -823,7 +823,7 @@ static void bench_bufferlistiter_deref(const size_t step, utime_t start = ceph_clock_now(); bufferlist::iterator iter = bl.begin(); - while (iter != bl.end()) { + while (iter.get_remaining() >= step) { iter += step; } utime_t end = ceph_clock_now(); From e16e6398b1e05bb3d98750dffa1989f5ad32d158 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Tue, 23 Jun 2026 14:16:38 +0800 Subject: [PATCH 398/596] test/bufferlist: shrink benchmark iterations under ASan The pure-timing *_bench/BenchAlloc loops repeat a stateless path millions of times. Under ASan that adds no memory-error coverage but inflates the run from minutes to hours, so cut the iteration counts when built with ASan. Signed-off-by: Sun Yuechi --- src/test/bufferlist.cc | 82 ++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/test/bufferlist.cc b/src/test/bufferlist.cc index 41b47c61029..629ab3e21d0 100644 --- a/src/test/bufferlist.cc +++ b/src/test/bufferlist.cc @@ -47,6 +47,18 @@ #define MAX_TEST 1000000 #define FILENAME "bufferlist" +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if __has_feature(address_sanitizer) || defined(__SANITIZE_ADDRESS__) +static constexpr bool bench_under_asan = true; +#else +static constexpr bool bench_under_asan = false; +#endif +static constexpr size_t asan_bench_rounds(size_t full) { + return bench_under_asan ? (full < 2 ? full : 2) : full; +} + using namespace std; static char cmd[128]; @@ -140,12 +152,13 @@ void bench_buffer_alloc(int size, int num) } TEST(Buffer, BenchAlloc) { - bench_buffer_alloc(16384, 1000000); - bench_buffer_alloc(4096, 1000000); - bench_buffer_alloc(1024, 1000000); - bench_buffer_alloc(256, 1000000); - bench_buffer_alloc(32, 1000000); - bench_buffer_alloc(4, 1000000); + const int n = asan_bench_rounds(1000000); + bench_buffer_alloc(16384, n); + bench_buffer_alloc(4096, n); + bench_buffer_alloc(1024, n); + bench_buffer_alloc(256, n); + bench_buffer_alloc(32, n); + bench_buffer_alloc(4, n); } TEST(BufferRaw, ostream) { @@ -544,7 +557,7 @@ TEST(BufferPtr, copy_out_bench) { for (int s=1; s<=8; s*=2) { utime_t start = ceph_clock_now(); int buflen = 1048576; - int count = 1000; + int count = asan_bench_rounds(1000); uint64_t v; for (int i=0; i src = { 0, }; - constexpr size_t rounds = 4000; + constexpr size_t rounds = asan_bench_rounds(4000); constexpr int conc_bl = 400; std::vector bls(conc_bl); @@ -1439,7 +1453,7 @@ TEST(BufferList, append_bench) { std::array src = { 0, }; for (size_t step = 4; step <= 16384; step *= 4) { const utime_t start = ceph_clock_now(); - constexpr size_t rounds = 4000; + constexpr size_t rounds = asan_bench_rounds(4000); for (size_t r = 0; r < rounds; ++r) { ceph::bufferlist bl; for (auto iter = std::begin(src); @@ -1456,7 +1470,7 @@ TEST(BufferList, append_bench) { TEST(BufferList, append_bench2) { std::array src = { 0, }; - constexpr size_t rounds = 4000; + constexpr size_t rounds = asan_bench_rounds(4000); constexpr int conc_bl = 400; std::vector bls(conc_bl); @@ -1488,7 +1502,7 @@ TEST(BufferList, append_hole_bench) { for (size_t step = 512; step <= 65536; step *= 2) { const utime_t start = ceph_clock_now(); - constexpr size_t rounds = 80000; + constexpr size_t rounds = asan_bench_rounds(80000); for (size_t r = 0; r < rounds; ++r) { ceph::bufferlist bl; while (bl.length() < targeted_bl_size) { @@ -1503,7 +1517,7 @@ TEST(BufferList, append_hole_bench) { TEST(BufferList, append_hole_bench2) { constexpr size_t targeted_bl_size = 1048576; - constexpr size_t rounds = 80000; + constexpr size_t rounds = asan_bench_rounds(80000); constexpr int conc_bl = 400; std::vector bls(conc_bl); From 946c23055c20fa8e194f810eb984f9ec083b9cb3 Mon Sep 17 00:00:00 2001 From: Guillaume Abrioux Date: Tue, 23 Jun 2026 14:36:22 +0200 Subject: [PATCH 399/596] cephadm: fix staggered upgrade Do not mark an upgrade complete until daemons in the filtered scope match the target image. Limited (--limit) upgrades still complete when remaining_count reaches zero. Fixes: https://tracker.ceph.com/issues/62959 Signed-off-by: Guillaume Abrioux --- src/pybind/mgr/cephadm/tests/test_upgrade.py | 118 +++++++++++++++++++ src/pybind/mgr/cephadm/upgrade.py | 52 ++++++++ 2 files changed, 170 insertions(+) diff --git a/src/pybind/mgr/cephadm/tests/test_upgrade.py b/src/pybind/mgr/cephadm/tests/test_upgrade.py index 98ca31c0600..dd28c5fd25f 100644 --- a/src/pybind/mgr/cephadm/tests/test_upgrade.py +++ b/src/pybind/mgr/cephadm/tests/test_upgrade.py @@ -1052,3 +1052,121 @@ def test_staggered_upgrade_validation( else: cephadm_module.upgrade._validate_upgrade_filters( 'new_image_name', daemon_types, hosts, services) + + +@mock.patch("cephadm.module.HostCache.get_daemons") +def test_filtered_scope_up_to_date( + get_daemons: mock.MagicMock, + cephadm_module: CephadmOrchestrator, +) -> None: + target_digest = 'new_image@repo_digest' + old_digest = 'old_image@repo_digest' + + cephadm_module.upgrade.upgrade_state = UpgradeState( + 'target_image', + '0', + target_digests=[target_digest], + daemon_types=['mon'], + hosts=['trial031'], + ) + + get_daemons.return_value = [ + DaemonDescription( + daemon_type='mon', + daemon_id='a', + hostname='trial031', + container_image_digests=[target_digest], + ), + DaemonDescription( + daemon_type='mon', + daemon_id='c', + hostname='trial031', + container_image_digests=[old_digest], + ), + ] + + assert not cephadm_module.upgrade._filtered_scope_up_to_date( + [target_digest], 'target_image', + ) + + get_daemons.return_value[1].container_image_digests = [target_digest] + assert cephadm_module.upgrade._filtered_scope_up_to_date( + [target_digest], 'target_image', + ) + + +@mock.patch.object(CephadmUpgrade, '_mark_upgrade_complete') +@mock.patch.object(CephadmUpgrade, '_filtered_scope_up_to_date', return_value=False) +@mock.patch.object(CephadmUpgrade, '_handle_need_upgrade_self') +@mock.patch.object(CephadmUpgrade, '_to_upgrade', return_value=(True, [])) +@mock.patch.object( + CephadmUpgrade, '_detect_need_upgrade', return_value=(False, [], [], 0), +) +@mock.patch.object(CephadmUpgrade, '_set_container_images') +@mock.patch.object(CephadmUpgrade, '_complete_osd_upgrade') +@mock.patch.object(CephadmUpgrade, '_complete_mds_upgrade') +@mock.patch.object(CephadmUpgrade, '_update_upgrade_progress') +@mock.patch.object(CephadmUpgrade, 'get_distinct_container_image_settings', return_value={}) +@mock.patch("cephadm.module.CephadmOrchestrator.lookup_release_name", return_value='tentacle') +@mock.patch("cephadm.module.CephadmOrchestrator.check_mon_command", return_value=(0, '{}', '')) +@mock.patch("cephadm.module.CephadmOrchestrator.set_container_image") +@mock.patch("cephadm.module.CephadmOrchestrator.get_active_mgr_digests") +@mock.patch("cephadm.module.CephadmOrchestrator.get", return_value={ + 'min_mon_release': 19, + 'require_osd_release': 'tentacle', + 'have_local_config_map': True, +}) +@mock.patch( + "cephadm.module.CephadmOrchestrator.version", + new_callable=mock.PropertyMock, + return_value='ceph version 19.3.0-0 (hash)', +) +@mock.patch("cephadm.module.HostCache.get_daemons") +def test_do_upgrade_limit_exhausted_marks_complete_without_scope_check( + get_daemons: mock.MagicMock, + _version: mock.MagicMock, + _get: mock.MagicMock, + get_active_mgr_digests: mock.MagicMock, + _set_container_image: mock.MagicMock, + _check_mon_command: mock.MagicMock, + _lookup_release_name: mock.MagicMock, + _get_distinct_container_image_settings: mock.MagicMock, + _update_upgrade_progress: mock.MagicMock, + _complete_mds_upgrade: mock.MagicMock, + _complete_osd_upgrade: mock.MagicMock, + _set_container_images: mock.MagicMock, + _detect_need_upgrade: mock.MagicMock, + _to_upgrade: mock.MagicMock, + _handle_need_upgrade_self: mock.MagicMock, + filtered_scope: mock.MagicMock, + mark_complete: mock.MagicMock, + cephadm_module: CephadmOrchestrator, +) -> None: + target_digest = 'new_image@repo_digest' + old_digest = 'old_image@repo_digest' + get_active_mgr_digests.return_value = [target_digest] + get_daemons.return_value = [ + DaemonDescription( + daemon_type='osd', + daemon_id=str(i), + hostname='host1', + container_image_digests=[target_digest] if i < 2 else [old_digest], + ) + for i in range(8) + ] + + cephadm_module.upgrade.upgrade_state = UpgradeState( + 'target_image', + '0', + target_id='image_id', + target_digests=[target_digest], + target_version='19.3.0-0', + daemon_types=['osd'], + total_count=2, + remaining_count=0, + ) + + cephadm_module.upgrade._do_upgrade() + + mark_complete.assert_called_once() + filtered_scope.assert_not_called() diff --git a/src/pybind/mgr/cephadm/upgrade.py b/src/pybind/mgr/cephadm/upgrade.py index 5d8a120c207..5d3d81dfc30 100644 --- a/src/pybind/mgr/cephadm/upgrade.py +++ b/src/pybind/mgr/cephadm/upgrade.py @@ -1692,6 +1692,46 @@ class CephadmUpgrade: self.upgrade_state.fs_original_allow_standby_replay = {} self._save_upgrade_state() + def _filtered_scope_up_to_date( + self, + target_digests: Optional[List[str]], + target_name: str, + ) -> bool: + assert self.upgrade_state is not None + if target_digests is None: + target_digests = [] + + if self.mgr.use_agent: + hosts: Set[str] = set() + if self.upgrade_state.hosts is not None: + hosts.update(self.upgrade_state.hosts) + for d in self._get_filtered_daemons(): + if d.hostname is not None: + hosts.add(d.hostname) + for hostname in hosts: + if not self.mgr.cache.host_metadata_up_to_date(hostname): + logger.info( + 'Upgrade: Waiting for host %s metadata before completing', + hostname, + ) + self.mgr.agent_helpers._request_ack_all_not_up_to_date() + return False + + for d in self._get_filtered_daemons(): + if d.daemon_type not in CEPH_IMAGE_TYPES: + continue + if ( + (self.mgr.use_repo_digest and d.matches_digests(target_digests)) + or (not self.mgr.use_repo_digest and d.matches_image_name(target_name)) + ): + continue + logger.info( + 'Upgrade: Waiting for %s to match target image before completing', + d.name(), + ) + return False + return True + def _mark_upgrade_complete(self) -> None: if not self.upgrade_state: logger.debug('_mark_upgrade_complete upgrade already marked complete, exiting') @@ -1948,5 +1988,17 @@ class CephadmUpgrade: 'who': name_to_config_section(daemon_type), }) + # Limited (--limit) upgrades end when the batch quota is exhausted, + # even if other daemons in the filter still need the target image. + if ( + self.upgrade_state.remaining_count is not None + and self.upgrade_state.remaining_count <= 0 + ): + self._mark_upgrade_complete() + return + if not self._filtered_scope_up_to_date( + target_digests, self.upgrade_state._target_name, + ): + return self._mark_upgrade_complete() return From aa9247a36cfc26b791ce10efab0e1eae7624faae Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Fri, 5 Jun 2026 17:01:25 +0800 Subject: [PATCH 400/596] crimson/os/seastore/journal: change CircularBoundedJournal::replay() to use coroutine Signed-off-by: Xuehan Xu --- .../journal/circular_bounded_journal.cc | 156 ++++++++---------- 1 file changed, 71 insertions(+), 85 deletions(-) diff --git a/src/crimson/os/seastore/journal/circular_bounded_journal.cc b/src/crimson/os/seastore/journal/circular_bounded_journal.cc index 9ab7d629f59..62e6c175aed 100644 --- a/src/crimson/os/seastore/journal/circular_bounded_journal.cc +++ b/src/crimson/os/seastore/journal/circular_bounded_journal.cc @@ -332,94 +332,80 @@ Journal::replay_ret CircularBoundedJournal::replay( /* * read records from last applied record prior to written_to, and replay */ + auto d_handler = std::move(delta_handler); LOG_PREFIX(CircularBoundedJournal::replay); - return cjs.read_header( - ).handle_error( + auto p = co_await cjs.read_header().handle_error( open_for_mount_ertr::pass_further{}, - crimson::ct_error::assert_all( - "Invalid error read_header" - )).safe_then([this, FNAME, delta_handler=std::move(delta_handler)](auto p) - mutable { - auto &[head, bl] = *p; - cjs.set_cbj_header(head); - DEBUG("header : {}", cjs.get_cbj_header()); - cjs.set_initialized(true); - return seastar::do_with( - std::move(delta_handler), - std::map(), - std::map>(), - [this](auto &d_handler, auto &map, auto &crc_info) { - auto build_paddr_seq_map = [&map]( - const auto &offsets, - const auto &e, - sea_time_point modify_time) - { - if (e.type == extent_types_t::ALLOC_INFO) { - alloc_delta_t alloc_delta; - decode(alloc_delta, e.bl); - if (alloc_delta.op == alloc_delta_t::op_types_t::CLEAR) { - for (auto &alloc_blk : alloc_delta.alloc_blk_ranges) { - map[alloc_blk.paddr] = offsets.write_result.start_seq; - } - } - } - return replay_ertr::make_ready_future(true); - }; - auto tail = get_dirty_tail() <= get_alloc_tail() ? - get_dirty_tail() : get_alloc_tail(); - set_written_to(tail); - // The first pass to build the paddr->journal_seq_t map - // from extent allocations - return scan_valid_record_delta(std::move(build_paddr_seq_map), tail - ).safe_then([this, &map, &d_handler, tail, &crc_info]() { - auto call_d_handler_if_valid = [this, &map, &d_handler, &crc_info]( - const auto &offsets, - const auto &e, - sea_time_point modify_time) - { - if (map.find(e.paddr) == map.end() || - map[e.paddr] <= offsets.write_result.start_seq) { - return d_handler( - offsets, - e, - get_dirty_tail(), - get_alloc_tail(), - modify_time - ).safe_then([&e, &crc_info](auto ret) { - auto [applied, ext] = ret; - if (applied && ext && can_inplace_rewrite( - ext->get_type())) { - crc_info[ext->get_paddr()] = - std::make_pair(ext, e.final_crc); - } - return replay_ertr::make_ready_future(applied); - }); - } - return replay_ertr::make_ready_future(true); - }; - // The second pass to replay deltas - return scan_valid_record_delta(std::move(call_d_handler_if_valid), tail - ).safe_then([&crc_info]() { - for (auto p : crc_info) { - ceph_assert_always(p.second.first->get_last_committed_crc() == p.second.second); - } - crc_info.clear(); - return replay_ertr::now(); - }); - }); - }).safe_then([this]() { - // make sure that committed_to is JOURNAL_SEQ_NULL if jounal is the initial state - if (get_written_to() != - journal_seq_t{0, - convert_abs_addr_to_paddr(get_records_start(), - get_device_id())}) { - record_submitter.update_committed_to(get_written_to()); + crimson::ct_error::assert_all("Invalid error read_header")); + auto &[head, bl] = *p; + cjs.set_cbj_header(head); + DEBUG("header : {}", cjs.get_cbj_header()); + cjs.set_initialized(true); + std::map map; + std::map> crc_info; + auto build_paddr_seq_map = [&map]( + const auto &offsets, + const auto &e, + sea_time_point modify_time) + { + if (e.type == extent_types_t::ALLOC_INFO) { + alloc_delta_t alloc_delta; + decode(alloc_delta, e.bl); + if (alloc_delta.op == alloc_delta_t::op_types_t::CLEAR) { + for (auto &alloc_blk : alloc_delta.alloc_blk_ranges) { + map[alloc_blk.paddr] = offsets.write_result.start_seq; + } } - trimmer.update_journal_tails( - get_dirty_tail(), - get_alloc_tail()); - }); - }); + } + return replay_ertr::make_ready_future(true); + }; + auto tail = get_dirty_tail() <= get_alloc_tail() ? + get_dirty_tail() : get_alloc_tail(); + set_written_to(tail); + // The first pass to build the paddr->journal_seq_t map + // from extent allocations + co_await scan_valid_record_delta(std::move(build_paddr_seq_map), tail); + auto call_d_handler_if_valid = [this, &map, &d_handler, &crc_info]( + const auto &offsets, + const auto &e, + sea_time_point modify_time) + { + if (map.find(e.paddr) == map.end() || + map[e.paddr] <= offsets.write_result.start_seq) { + return d_handler( + offsets, + e, + get_dirty_tail(), + get_alloc_tail(), + modify_time + ).safe_then([&e, &crc_info](auto ret) { + auto [applied, ext] = ret; + if (applied && ext && can_inplace_rewrite( + ext->get_type())) { + crc_info[ext->get_paddr()] = + std::make_pair(ext, e.final_crc); + } + return replay_ertr::make_ready_future(applied); + }); + } + return replay_ertr::make_ready_future(true); + }; + // The second pass to replay deltas + co_await scan_valid_record_delta(std::move(call_d_handler_if_valid), tail); + for (auto p : crc_info) { + ceph_assert_always(p.second.first->get_last_committed_crc() == p.second.second); + } + crc_info.clear(); + // make sure that committed_to is JOURNAL_SEQ_NULL if jounal is the initial state + if (get_written_to() != + journal_seq_t{0, + convert_abs_addr_to_paddr(get_records_start(), + get_device_id())}) { + record_submitter.update_committed_to(get_written_to()); + } + trimmer.update_journal_tails( + get_dirty_tail(), + get_alloc_tail()); } void CircularBoundedJournal::register_metrics() From bf0e7afecbab833e015710daaef5e4e7343bfe74 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sun, 21 Jun 2026 19:59:46 +0800 Subject: [PATCH 401/596] crimson/os/seastore/random_block_manager/avlallocator: avoid directly modifying avl_set elements Fixes: https://tracker.ceph.com/issues/77549 Signed-off-by: Xuehan Xu --- .../random_block_manager/avlallocator.cc | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/crimson/os/seastore/random_block_manager/avlallocator.cc b/src/crimson/os/seastore/random_block_manager/avlallocator.cc index 3f8df06f9d6..e23b2a76426 100644 --- a/src/crimson/os/seastore/random_block_manager/avlallocator.cc +++ b/src/crimson/os/seastore/random_block_manager/avlallocator.cc @@ -46,30 +46,32 @@ void AvlAllocator::_remove_from_tree(rbm_abs_addr start, rbm_abs_addr size) bool left_over = (rs->start != start); bool right_over = (rs->end != end); + auto range = extent_range_t{rs->start, rs->end}; _extent_size_tree_rm(*rs); + auto insert_pos = extent_tree.erase_and_dispose(rs, dispose_rs{}); if (left_over && right_over) { - auto old_right_end = rs->end; - auto insert_pos = rs; - ceph_assert(insert_pos != extent_tree.end()); - ++insert_pos; - rs->end = start; + auto prange = new extent_range_t(range); + auto old_right_end = prange->end; + prange->end = start; auto r = new extent_range_t{end, old_right_end}; - extent_tree.insert_before(insert_pos, *r); - extent_size_tree.insert(*r); - available_size += r->length(); - _extent_size_tree_try_insert(*rs); + insert_pos = extent_tree.insert_before(insert_pos, *r); + extent_tree.insert_before(insert_pos, *prange); + _extent_size_tree_try_insert(*r); + _extent_size_tree_try_insert(*prange); } else if (left_over) { + auto prange = new extent_range_t(range); assert(is_aligned(start, block_size)); - rs->end = start; - _extent_size_tree_try_insert(*rs); + prange->end = start; + extent_tree.insert_before(insert_pos, *prange); + _extent_size_tree_try_insert(*prange); } else if (right_over) { + auto prange = new extent_range_t(range); assert(is_aligned(end, block_size)); - rs->start = end; - _extent_size_tree_try_insert(*rs); - } else { - extent_tree.erase_and_dispose(rs, dispose_rs{}); + prange->start = end; + extent_tree.insert_before(insert_pos, *prange); + _extent_size_tree_try_insert(*prange); } } @@ -135,24 +137,29 @@ void AvlAllocator::_add_to_tree(rbm_abs_addr start, rbm_abs_addr size) bool merge_after = (rs_after != extent_tree.end() && rs_after->start == end); if (merge_before && merge_after) { + auto range = new extent_range_t{rs_before->start, rs_after->end}; _extent_size_tree_rm(*rs_before); _extent_size_tree_rm(*rs_after); - rs_after->start = rs_before->start; - extent_tree.erase_and_dispose(rs_before, dispose_rs{}); - _extent_size_tree_try_insert(*rs_after); + rs_after = extent_tree.erase_and_dispose(rs_before, dispose_rs{}); + auto insert_pos = extent_tree.erase_and_dispose(rs_after, dispose_rs{}); + _extent_size_tree_try_insert(*range); + extent_tree.insert_before(insert_pos, *range); } else if (merge_before) { + auto range = new extent_range_t{rs_before->start, end}; _extent_size_tree_rm(*rs_before); - rs_before->end = end; - _extent_size_tree_try_insert(*rs_before); + _extent_size_tree_try_insert(*range); + auto insert_pos = extent_tree.erase_and_dispose(rs_before, dispose_rs{}); + extent_tree.insert_before(insert_pos, *range); } else if (merge_after) { + auto range = new extent_range_t{start, rs_after->end}; _extent_size_tree_rm(*rs_after); - rs_after->start = start; - _extent_size_tree_try_insert(*rs_after); + _extent_size_tree_try_insert(*range); + auto insert_pos = extent_tree.erase_and_dispose(rs_after, dispose_rs{}); + extent_tree.insert_before(insert_pos, *range); } else { auto r = new extent_range_t{start, end}; extent_tree.insert(*r); - extent_size_tree.insert(*r); - available_size += r->length(); + _extent_size_tree_try_insert(*r); } } From bdbfa8a15a04a9d0fc22e6059967dffcf4e2c276 Mon Sep 17 00:00:00 2001 From: Abhishek Desai Date: Wed, 10 Jun 2026 16:13:16 +0530 Subject: [PATCH 402/596] mgr/dashboard : Fix feedback module enablement fixes : https://tracker.ceph.com/issues/75734 Signed-off-by: Abhishek Desai --- qa/tasks/mgr/dashboard/test_feedback.py | 2 +- .../mgr/dashboard/controllers/mgr_modules.py | 22 +++++++-- .../mgr-module-list.component.spec.ts | 2 +- .../shared/feedback/feedback.component.html | 49 +++++++++---------- .../feedback/feedback.component.spec.ts | 41 +++++++++++++++- .../shared/feedback/feedback.component.ts | 22 +++++++-- .../src/app/shared/api/feedback.service.ts | 11 ++++- .../app/shared/api/mgr-module.service.spec.ts | 17 ++++++- .../src/app/shared/api/mgr-module.service.ts | 14 ++++-- src/pybind/mgr/dashboard/openapi.yaml | 15 +++++- 10 files changed, 152 insertions(+), 43 deletions(-) diff --git a/qa/tasks/mgr/dashboard/test_feedback.py b/qa/tasks/mgr/dashboard/test_feedback.py index a395adf4865..39d736ea08c 100644 --- a/qa/tasks/mgr/dashboard/test_feedback.py +++ b/qa/tasks/mgr/dashboard/test_feedback.py @@ -6,7 +6,7 @@ class FeedbackTest(MgrModuleTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls._ceph_cmd(['mgr', 'module', 'enable', 'feedback'], wait=3) + cls._ceph_cmd(['mgr', 'module', 'enable', 'feedback', '--force'], wait=3) # Point the feedback module at an unreachable host so the test # does not depend on tracker.ceph.com being available. Any # create_issue call will fail fast with a ConnectionError diff --git a/src/pybind/mgr/dashboard/controllers/mgr_modules.py b/src/pybind/mgr/dashboard/controllers/mgr_modules.py index 57bb9b5ffb8..249c111e00c 100644 --- a/src/pybind/mgr/dashboard/controllers/mgr_modules.py +++ b/src/pybind/mgr/dashboard/controllers/mgr_modules.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- +from typing import Optional + from .. import mgr from ..security import Scope from ..services.ceph_service import CephService from ..services.exception import handle_send_command_error from ..tools import find_object_in_list, str_to_bool -from . import APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body +from . import APIDoc, APIRouter, EndpointDoc, Param, RESTController, allow_empty_body MGR_MODULE_SCHEMA = ([{ "name": (str, "Module Name"), @@ -94,15 +96,27 @@ class MgrModules(RESTController): @RESTController.Resource('POST') @handle_send_command_error('mgr_modules') @allow_empty_body - def enable(self, module_name): + @EndpointDoc("Enable Mgr module", + parameters={ + 'force': Param( + bool, + 'Force enablement when not all mgr daemons support the module', + True, + False) + }) + def enable(self, module_name, force: Optional[bool] = False): """ Enable the specified Ceph Mgr module. :param module_name: The name of the Ceph Mgr module. :type module_name: str + :param force: Force enablement when not all mgr daemons support the module. + :type force: bool """ assert self._is_module_managed(module_name) - CephService.send_command( - 'mon', 'mgr module enable', module=module_name) + cmd_kwargs = {'module': module_name} + if force: + cmd_kwargs['force'] = True + CephService.send_command('mon', 'mgr module enable', **cmd_kwargs) @RESTController.Resource('POST') @handle_send_command_error('mgr_modules') diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts index 68fad64f689..ffe59f37e37 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts @@ -146,7 +146,7 @@ describe('MgrModuleListComponent', () => { tick(mgrModuleService.REFRESH_INTERVAL); tick(mgrModuleService.REFRESH_INTERVAL); tick(mgrModuleService.REFRESH_INTERVAL); - expect(mgrModuleService.enable).toHaveBeenCalledWith('foo'); + expect(mgrModuleService.enable).toHaveBeenCalledWith('foo', false); expect(mgrModuleService.list).toHaveBeenCalledTimes(2); expect(component.table.refreshBtn).toHaveBeenCalled(); })); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html index 50a1804f35d..270f88c8443 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.html @@ -17,13 +17,13 @@ class="modal-wrapper" > @if (!isFeedbackEnabled) { - - In order to report an issue, Feedback module must be enabled. + Enable the feedback service to report issues. } @@ -31,14 +31,13 @@
+ API key @if (feedbackForm.showError('api_key', formDir, 'required')) { Ceph Tracker API key is required. + i18n>API key is required. } @if (feedbackForm.showError('api_key', formDir, 'invalidApiKey')) { Ceph Tracker API key is invalid. + i18n>API key is invalid. } - You can obtain your API key from - https://tracker.ceph.com/my/account - + + Enter your Ceph Tracker API key. You can find your API key in your + Ceph Tracker account. +
} @@ -72,12 +72,10 @@ label="Project name" id="project" formControlName="project" - cdRequiredField="Project name" [invalid]="!feedbackForm.controls.project.valid && feedbackForm.controls.project.dirty" [invalidText]="projectError" i18n > - @for (project of projects; track project) { } @@ -90,25 +88,24 @@
- +
Tracker - @for (trackerName of tracker; track trackerName) { - + > + @for (issueType of issueTypes; track issueType.value) { + } @if (feedbackForm.showError('tracker', formDir, 'required')) { - Tracker name is required. + Issue type is required. }
@@ -116,25 +113,25 @@
- Subject + Issue title @if (feedbackForm.showError('subject', formDir, 'required')) { - Subject is required. + Issue title is required. }
@@ -142,7 +139,6 @@
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts index c5e27b99054..d8faa782a76 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.spec.ts @@ -5,9 +5,10 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { throwError } from 'rxjs'; +import { throwError, of as observableOf } from 'rxjs'; import { FeedbackService } from '~/app/shared/api/feedback.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; import { ComponentsModule } from '~/app/shared/components/components.module'; import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; import { FeedbackComponent } from './feedback.component'; @@ -18,6 +19,7 @@ describe('FeedbackComponent', () => { let component: FeedbackComponent; let fixture: ComponentFixture; let feedbackService: FeedbackService; + let mgrModuleService: MgrModuleService; let formHelper: FormHelper; configureTestBed({ @@ -37,6 +39,7 @@ describe('FeedbackComponent', () => { fixture = TestBed.createComponent(FeedbackComponent); component = fixture.componentInstance; feedbackService = TestBed.inject(FeedbackService); + mgrModuleService = TestBed.inject(MgrModuleService); fixture.detectChanges(); }); @@ -73,4 +76,40 @@ describe('FeedbackComponent', () => { formHelper.expectError('api_key', 'invalidApiKey'); }); + + it('should enable feedback module with force', () => { + spyOn(mgrModuleService, 'updateModuleState'); + spyOn(mgrModuleService.updateCompleted$, 'subscribe').and.callThrough(); + + component.enableFeedbackModule(); + + expect(mgrModuleService.updateModuleState).toHaveBeenCalledWith( + 'feedback', + false, + null, + null, + 'Enabled Feedback Module', + false, + undefined, + true + ); + expect(mgrModuleService.updateCompleted$.subscribe).toHaveBeenCalled(); + }); + + it('should refresh feedback state after module enablement', () => { + spyOn(feedbackService, 'isKeyExist').and.returnValues( + throwError({ status: 404 }), + observableOf(true) + ); + spyOn(mgrModuleService, 'updateModuleState'); + + component.ngOnInit(); + expect(component.isFeedbackEnabled).toEqual(false); + + component.enableFeedbackModule(); + mgrModuleService.updateCompleted$.next(); + + expect(component.isFeedbackEnabled).toEqual(true); + expect(component.feedbackForm.enabled).toEqual(true); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts index 40509cbe797..655358050ec 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/feedback/feedback.component.ts @@ -28,7 +28,10 @@ export class FeedbackComponent extends CdForm implements OnInit, OnDestroy { 'ceph_volume', 'core_ceph' ]; - tracker: string[] = ['bug', 'feature']; + issueTypes = [ + { value: 'bug', label: $localize`Bug` }, + { value: 'feature', label: $localize`Feature request` } + ]; api_key: string; keySub: Subscription; submit: string; @@ -48,8 +51,15 @@ export class FeedbackComponent extends CdForm implements OnInit, OnDestroy { ngOnInit() { this.createForm(); + this.loadFeedbackState(); + } + + private loadFeedbackState() { + this.keySub?.unsubscribe(); this.keySub = this.feedbackService.isKeyExist().subscribe({ next: (data: boolean) => { + this.isFeedbackEnabled = true; + this.feedbackForm.enable(); this.isAPIKeySet = data; if (this.isAPIKeySet) { this.feedbackForm.get('api_key').clearValidators(); @@ -64,8 +74,8 @@ export class FeedbackComponent extends CdForm implements OnInit, OnDestroy { private createForm() { this.feedbackForm = new CdFormGroup({ - project: new UntypedFormControl('', Validators.required), - tracker: new UntypedFormControl(this.tracker[0], Validators.required), + project: new UntypedFormControl(this.projects[0], Validators.required), + tracker: new UntypedFormControl(this.issueTypes[0].value, Validators.required), subject: new UntypedFormControl('', Validators.required), description: new UntypedFormControl('', Validators.required), api_key: new UntypedFormControl('', Validators.required) @@ -110,7 +120,13 @@ export class FeedbackComponent extends CdForm implements OnInit, OnDestroy { null, null, 'Enabled Feedback Module', + false, + undefined, true ); + const subscription = this.mgrModuleService.updateCompleted$.subscribe(() => { + subscription.unsubscribe(); + this.loadFeedbackState(); + }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts index c450bbe076f..b3ea2ffc15b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts @@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import * as _ from 'lodash'; +import { throwError as observableThrowError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -11,7 +13,14 @@ export class FeedbackService { baseUIURL = 'api/feedback'; isKeyExist() { - return this.http.get('ui-api/feedback/api_key/exist'); + return this.http.get('ui-api/feedback/api_key/exist').pipe( + catchError((error) => { + if (_.isFunction(error.preventDefault)) { + error.preventDefault(); + } + return observableThrowError(() => error); + }) + ); } createIssue( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts index 0f65e6ebc0f..00ed2fe4c39 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts @@ -61,6 +61,21 @@ describe('MgrModuleService', () => { service.enable('foo').subscribe(); const req = httpTesting.expectOne('api/mgr/module/foo/enable'); expect(req.request.method).toBe('POST'); + expect(req.request.body).toBeNull(); + }); + + it('should call enable with force for whitelisted modules', () => { + service.enable('feedback').subscribe(); + const req = httpTesting.expectOne('api/mgr/module/feedback/enable'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ force: true }); + }); + + it('should call enable with explicit force', () => { + service.enable('foo', true).subscribe(); + const req = httpTesting.expectOne('api/mgr/module/foo/enable'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ force: true }); }); it('should call disable', () => { @@ -106,7 +121,7 @@ describe('MgrModuleService', () => { tick(service.REFRESH_INTERVAL); tick(service.REFRESH_INTERVAL); tick(service.REFRESH_INTERVAL); - expect(service.enable).toHaveBeenCalledWith('foo'); + expect(service.enable).toHaveBeenCalledWith('foo', false); expect(service.list).toHaveBeenCalledTimes(2); expect(notificationService.suspendToasties).toHaveBeenCalledTimes(2); expect(blockUIService.start).toHaveBeenCalled(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts index 7826523b5d0..53a508b01f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts @@ -13,6 +13,9 @@ import { SummaryService } from '../services/summary.service'; const GLOBAL = 'global'; +/** Modules that require --force when not all mgr daemons support them. */ +const FORCE_ENABLE_MODULES = new Set(['feedback']); + @Injectable({ providedIn: 'root' }) @@ -60,9 +63,11 @@ export class MgrModuleService { /** * Enable the Ceph Mgr module. * @param {string} module The name of the mgr module. + * @param {boolean} force Force enablement when not all mgr daemons support the module. */ - enable(module: string) { - return this.http.post(`${this.url}/${module}/enable`, null); + enable(module: string, force: boolean = false) { + const useForce = force || FORCE_ENABLE_MODULES.has(module); + return this.http.post(`${this.url}/${module}/enable`, useForce ? { force: true } : null); } /** @@ -92,9 +97,10 @@ export class MgrModuleService { navigateTo: string = '', notificationText?: string, navigateByUrl?: boolean, - reconnectingMessage: string = $localize`Reconnecting, please wait ...` + reconnectingMessage: string = $localize`Reconnecting, please wait ...`, + force: boolean = false ): void { - const moduleToggle$ = enabled ? this.disable(module) : this.enable(module); + const moduleToggle$ = enabled ? this.disable(module) : this.enable(module, force); moduleToggle$.subscribe({ next: () => { diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 28d785dfaeb..83f0ca14911 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -10435,13 +10435,25 @@ paths: post: description: "\n Enable the specified Ceph Mgr module.\n :param\ \ module_name: The name of the Ceph Mgr module.\n :type module_name:\ - \ str\n " + \ str\n :param force: Force enablement when not all mgr daemons support\ + \ the module.\n :type force: bool\n " parameters: - in: path name: module_name required: true schema: type: string + requestBody: + content: + application/json: + schema: + properties: + force: + default: false + description: Force enablement when not all mgr daemons support the + module + type: boolean + type: object responses: '201': content: @@ -10472,6 +10484,7 @@ paths: trace. security: - jwt: [] + summary: Enable Mgr module tags: - MgrModule /api/mgr/module/{module_name}/options: From cfd916b1b4190c1d961efcc700feba9164e9dd87 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sun, 21 Jun 2026 20:53:48 +0800 Subject: [PATCH 403/596] osd: speed up upmap underfull fallback According to our tests, this commit can speed up pg upmap calculations by orders of magnitude Performance analysis: Suppose: R = number of pg_upmap_items (the full candidate pool, after to_skip/only_pools filtering) M = how many OSDs the deviation_osd loop visits before it breaks (less or equal to underfull count) U = the number of distinct from OSDs in the upmap items. U is less or equal to number of OSDs, and should be less than R x k k = mapping pairs per candidate (usually 1, small) Time complexity of OSDMap::calc_pg_upmaps(): Before the commit: O(M * R) After the commit: O(R * k + M * log(U)) ~= O(R + M *log(U)) Performant evaluation: Existing upmap items Underfull OSDs Old path New path Speedup 50000 1000 6.51s 0.047s 137x 100000 1000 12.05s 0.086s 140x 200000 2000 63.93s 0.281s 227x Fixes: https://tracker.ceph.com/issues/77563 Signed-off-by: Xuehan Xu Assisted-by: Codex:GPT-5.5 --- src/osd/OSDMap.cc | 58 +++++++++++++++++++++++++++-------------------- src/osd/OSDMap.h | 3 ++- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/osd/OSDMap.cc b/src/osd/OSDMap.cc index 59daa0a156f..7579e054c8e 100644 --- a/src/osd/OSDMap.cc +++ b/src/osd/OSDMap.cc @@ -5981,28 +5981,32 @@ int OSDMap::calc_pg_upmaps( ceph_assert(!(to_unmap.size() || to_upmap.size())); ldout(cct, 10) << " failed to find any changes for overfull osds" << dendl; - for (auto& [deviation, osd] : deviation_osd) { - if (std::find(underfull.begin(), underfull.end(), osd) == - underfull.end()) - break; - float target = osd_weight[osd] * pgs_per_weight; - ceph_assert(target > 0); - if (fabsf(deviation) < max_deviation) { - // respect max_deviation too - ldout(cct, 10) << " osd." << osd - << " target " << target - << " deviation " << deviation - << " -> absolute " << fabsf(deviation) - << " < max " << max_deviation - << dendl; - break; - } - // look for remaps we can un-remap - candidates_t candidates = build_candidates(cct, tmp_osd_map, to_skip, - only_pools, aggressive, p_seed); - if (try_drop_remap_underfull(cct, candidates, osd, temp_pgs_by_osd, - to_unmap, to_upmap)) { - goto test_change; + { + const auto candidates_by_osd = build_candidates_by_osd( + cct, tmp_osd_map, to_skip, only_pools, aggressive, p_seed); + for (auto& [deviation, osd] : deviation_osd) { + if (std::find(underfull.begin(), underfull.end(), osd) == + underfull.end()) + break; + float target = osd_weight[osd] * pgs_per_weight; + ceph_assert(target > 0); + if (fabsf(deviation) < max_deviation) { + // respect max_deviation too + ldout(cct, 10) << " osd." << osd + << " target " << target + << " deviation " << deviation + << " -> absolute " << fabsf(deviation) + << " < max " << max_deviation + << dendl; + break; + } + // look for remaps we can un-remap + auto candidates = candidates_by_osd.find(osd); + if (candidates != candidates_by_osd.end() && + try_drop_remap_underfull(cct, candidates->second, osd, + temp_pgs_by_osd, to_unmap, to_upmap)) { + goto test_change; + } } } @@ -6527,7 +6531,7 @@ int OSDMap::find_best_remap ( return best_pos; } -OSDMap::candidates_t OSDMap::build_candidates( +OSDMap::candidates_by_osd_t OSDMap::build_candidates_by_osd( CephContext *cct, const OSDMap& tmp_osd_map, const set to_skip, @@ -6551,7 +6555,13 @@ OSDMap::candidates_t OSDMap::build_candidates( // shuffle candidates so they all get equal (in)attention std::shuffle(candidates.begin(), candidates.end(), get_random_engine(cct, p_seed)); } - return candidates; + candidates_by_osd_t candidates_by_osd; + for (auto& candidate : candidates) { + for (auto& mapping : candidate.second) { + candidates_by_osd[mapping.first].push_back(candidate); + } + } + return candidates_by_osd; } // return -1 if all PGs are OK, else the first PG which includes only zero PA OSDs diff --git a/src/osd/OSDMap.h b/src/osd/OSDMap.h index 202475ab16e..0faaad96d38 100644 --- a/src/osd/OSDMap.h +++ b/src/osd/OSDMap.h @@ -1638,6 +1638,7 @@ private: // Bunch of internal functions used only by calc_pg_upmaps (result of c typedef std::vector>>> candidates_t; +typedef std::map candidates_by_osd_t; bool try_drop_remap_underfull( CephContext *cct, @@ -1669,7 +1670,7 @@ bool try_drop_remap_underfull( const std::map osd_deviation ); - candidates_t build_candidates( + candidates_by_osd_t build_candidates_by_osd( CephContext *cct, const OSDMap& tmp_osd_map, const std::set to_skip, From cb2321f7fa6821d6d96a790f403a65410e095654 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Wed, 24 Jun 2026 15:31:11 +0800 Subject: [PATCH 404/596] osd/OSDMap: fix the pgs_per_weight calculation error when multiple pools are being balanced Before this commit, When multiple pools are being balanced, pgs_per_weight only considers the last pool, which is wrong. Signed-off-by: Xuehan Xu --- src/osd/OSDMap.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/osd/OSDMap.cc b/src/osd/OSDMap.cc index 7579e054c8e..a82cb3cea68 100644 --- a/src/osd/OSDMap.cc +++ b/src/osd/OSDMap.cc @@ -6197,7 +6197,7 @@ float OSDMap::build_pool_pgs_info ( } total_pgs += pdata.get_size() * pdata.get_pg_num(); - osds_weight_total = get_osds_weight(cct, tmp_osd_map, pid, osds_weight); + osds_weight_total += get_osds_weight(cct, tmp_osd_map, pid, osds_weight); } for (auto& [oid, oweight] : osds_weight) { int pgs = 0; From 78e74e8d71513e06f19d9ec8a667f3cd5bebd07f Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Mon, 22 Jun 2026 20:47:39 +0800 Subject: [PATCH 405/596] crimson/os/seastore/lba: non-existing LBACursors are strictly forbidden to refresh Signed-off-by: Xuehan Xu --- src/crimson/os/seastore/lba/lba_btree_node.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/crimson/os/seastore/lba/lba_btree_node.cc b/src/crimson/os/seastore/lba/lba_btree_node.cc index 5f64cba2217..bc6c5f38e3d 100644 --- a/src/crimson/os/seastore/lba/lba_btree_node.cc +++ b/src/crimson/os/seastore/lba/lba_btree_node.cc @@ -135,6 +135,7 @@ base_iertr::future<> LBACursor::refresh() modifications = leaf->modifications; iter = leaf->lower_bound(get_laddr()); + assert(iter == leaf->end() || iter.get_key() == get_laddr()); assert(is_viewable()); } From d22252b6b5a1948543bf69f6ce6a34b7a4f70104 Mon Sep 17 00:00:00 2001 From: Mark Kogan Date: Tue, 30 Apr 2024 17:22:29 +0300 Subject: [PATCH 406/596] rgw: reduce default thread pool size Fixes: https://tracker.ceph.com/issues/65656 Signed-off-by: Mark Kogan --- PendingReleaseNotes | 4 ++++ src/common/options/rgw.yaml.in | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 348fa282cc6..b3dc29c681b 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -59,6 +59,10 @@ * RGW: Fixed bucket notification events so the 'x_amz_request_id' in NotificationEvent now matches the 'x_amz_request_id' returned by the corresponding S3 operation. * RGW: The rgw_gc_max_deferred and rgw_gc_max_deferred_entries_size options have been removed, as they did not do anything. - Updated the default for rgw_gc_max_queue_size to account for the extra space from removing rgw_gc_max_deferred_entries_size. +* RGW: The default value of ``rgw_thread_pool_size`` has been reduced from 512 to 128. + Benchmarks showed that the lower thread count improves throughput and reduces latency + on NVMe and HDD based environments. Users with specific workload requirements can + tune this value; see also ``rgw_max_concurrent_requests``. * DASHBOARD: A new Overview landing page provides an at-a-glance cluster summary diff --git a/src/common/options/rgw.yaml.in b/src/common/options/rgw.yaml.in index 5f321fcaff6..b9f78b95ddc 100644 --- a/src/common/options/rgw.yaml.in +++ b/src/common/options/rgw.yaml.in @@ -1282,9 +1282,11 @@ options: is, RGW will be able to deal with more concurrent requests at the cost of more resource utilization. fmt_desc: The size of the thread pool. - default: 512 + default: 128 services: - rgw + see_also: + - rgw_max_concurrent_requests with_legacy: true - name: rgw_num_control_oids type: int @@ -3884,6 +3886,7 @@ options: - rgw see_also: - rgw_frontends + - rgw_thread_pool_size - name: rgw_scheduler_type type: str level: advanced From d3df37a61ba6bccdd1b88effb060680bea5d1a40 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Wed, 24 Jun 2026 18:10:41 +0800 Subject: [PATCH 407/596] crimson/osd: mark ThrottleReleaser [[nodiscard]] OperationThrottler::get_throttle() returns a ThrottleReleaser guard whose destructor calls release_throttle(), the mClock RequestCompletion that decrements in_progress. The guard has to be held for the whole operation: a caller that discards it at acquisition releases the slot in the same step, so the operation never counts against max_in_progress and the throttle does nothing. Mark the type [[nodiscard]] so the compiler flags a discarded co_await of get_throttle(). Signed-off-by: Kefu Chai --- src/crimson/osd/osd_operation.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/crimson/osd/osd_operation.h b/src/crimson/osd/osd_operation.h index 000cf4057ef..e41cc1abf52 100644 --- a/src/crimson/osd/osd_operation.h +++ b/src/crimson/osd/osd_operation.h @@ -376,7 +376,11 @@ public: return !max_in_progress || in_progress < max_in_progress; } - class ThrottleReleaser { + // The returned guard's destructor is the mClock RequestCompletion + // (release_throttle), so it must be held for the throttled operation's whole + // lifetime. Dropping it at acquisition frees the slot immediately and the + // operation never counts against max_in_progress. + class [[nodiscard("discarding the guard releases the throttle slot immediately")]] ThrottleReleaser { OperationThrottler *parent = nullptr; public: ThrottleReleaser(OperationThrottler *parent) : parent(parent) {} From 5c6e98176dd26f241f40de47f56eb771cc139c84 Mon Sep 17 00:00:00 2001 From: Matthew N Heler Date: Fri, 19 Jun 2026 18:10:03 -0500 Subject: [PATCH 408/596] mgr: filter root logger fallback Make the mgr root logger fallback follow debug_mgr and preserve the original logger name, so third-party DEBUG logs are not emitted by default or mislabeled as mgr logs. Signed-off-by: Matthew N Heler --- src/pybind/mgr/mgr_module.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/pybind/mgr/mgr_module.py b/src/pybind/mgr/mgr_module.py index 192eefbf96e..d7af7a86724 100644 --- a/src/pybind/mgr/mgr_module.py +++ b/src/pybind/mgr/mgr_module.py @@ -704,9 +704,8 @@ class MgrRootHandler(CPlusPlusHandler): "[mgr %(levelname)-4s %(name)s] %(message)s" )) - def emit(self, record: logging.LogRecord) -> None: - record.name = "mgr" - super().emit(record) + def set_module(self, module_inst: 'MgrModuleLoggingMixin') -> None: + self._module = module_inst class ClusterLogHandler(logging.Handler): @@ -744,6 +743,7 @@ class FileHandler(logging.FileHandler): class MgrModuleLoggingMixin(object): module_name: str + _root_log_handler: Optional[MgrRootHandler] = None def _configure_logging(self, mgr_level: str, @@ -766,9 +766,20 @@ class MgrModuleLoggingMixin(object): self.log_to_cluster = log_to_cluster root = logging.getLogger() - if not any(isinstance(h, MgrRootHandler) for h in root.handlers): - root.addHandler(MgrRootHandler(self)) - root.setLevel(logging.NOTSET) + root_handler = None + for handler in root.handlers: + if isinstance(handler, MgrRootHandler): + root_handler = handler + break + if root_handler is None: + root_handler = MgrRootHandler(self) + root.addHandler(root_handler) + else: + root_handler.set_module(self) + self._root_log_handler = root_handler + # Module loggers rely on handler thresholds, so keep root permissive + # and apply the mgr fallback threshold on MgrRootHandler itself. + root.setLevel(logging.NOTSET) self._module_logger.addHandler(self._mgr_log_handler) if log_to_file: @@ -796,6 +807,8 @@ class MgrModuleLoggingMixin(object): module_level: str, cluster_level: str) -> None: self._cluster_log_handler.setLevel(cluster_level.upper()) + if self._root_log_handler is not None: + self._root_log_handler.setLevel(self._ceph_log_level_to_python(mgr_level)) module_level = module_level.upper() if module_level else '' if not self._module_level: From 1262deedb2d5c796eecf5ba3ae7f6204b0be09a6 Mon Sep 17 00:00:00 2001 From: Abhishek Desai Date: Mon, 25 May 2026 00:19:39 +0530 Subject: [PATCH 409/596] mgr/dashboard : Carbonize configuration form fixes : https://tracker.ceph.com/issues/76778 Signed-off-by: Abhishek Desai --- .../controllers/cluster_configuration.py | 12 +- .../cypress/e2e/cluster/configuration.po.ts | 24 +- .../configuration-form.component.html | 381 +++++++++++------- .../configuration-form.component.scss | 12 - .../configuration-form.component.ts | 10 +- 5 files changed, 276 insertions(+), 163 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/cluster_configuration.py b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py index eeda4289eb8..6a9c278579b 100644 --- a/src/pybind/mgr/dashboard/controllers/cluster_configuration.py +++ b/src/pybind/mgr/dashboard/controllers/cluster_configuration.py @@ -54,7 +54,7 @@ class ClusterConfiguration(RESTController): module_options = cephadm_module_config.get('module_options', {}) for option_name, opt in module_options.items(): - current_value = mgr.get_module_option_ex( + mgr_value = mgr.get_module_option_ex( 'cephadm', option_name, opt.get('default_value')) option = dict(opt) @@ -64,11 +64,19 @@ class ClusterConfiguration(RESTController): option['enum_values'] = option.pop('enum_allowed', []) option['services'] = ['mgr'] option['can_update_at_runtime'] = True - option['value'] = [{'section': 'mgr', 'value': current_value}] + option['value'] = [] option['source'] = 'mgr_module' + option['_mgr_value'] = mgr_value cephadm_options.append(option) + self._append_config_option_values(cephadm_options) + + for option in cephadm_options: + mgr_value = option.pop('_mgr_value') + if not any(v['section'] == 'mgr' for v in option.get('value', [])): + option['value'].append({'section': 'mgr', 'value': mgr_value}) + return cephadm_options def _append_config_option_values(self, options): diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts index 920d118be87..6c2b4496528 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts @@ -5,6 +5,14 @@ export class ConfigurationPageHelper extends PageHelper { index: { url: '#/configuration', id: 'cd-configuration' } }; + private waitForEditForm(name: string) { + cy.contains('h3', `Edit ${name}`).should('be.visible'); + } + + private getSectionInput(section: string) { + return cy.get(`input#${section}`); + } + /** * Clears out all the values in a config to reset before and after testing * Does not work for configs with checkbox only, possible future PR @@ -14,16 +22,16 @@ export class ConfigurationPageHelper extends PageHelper { const valList = ['global', 'mon', 'mgr', 'osd', 'mds', 'client']; // Editable values this.getFirstTableCell(name).click(); cy.contains('button', 'Edit').click(); - // Waits for the data to load - cy.contains('.card-header', `Edit ${name}`); + this.waitForEditForm(name); for (const i of valList) { - cy.get(`#${i}`).clear(); + this.getSectionInput(i).clear({ force: true }).blur({ force: true }); } // Clicks save button and checks that values are not present for the selected config cy.get('[data-testid=submitBtn]').click(); - cy.wait(3 * 1000); + cy.url().should('include', '#/configuration'); + cy.get(this.pages.index.id); this.clearFilter(); @@ -54,18 +62,18 @@ export class ConfigurationPageHelper extends PageHelper { this.getFirstTableCell(name).click(); cy.contains('button', 'Edit').click(); - // Waits for data to load - cy.contains('.card-header', `Edit ${name}`); + this.waitForEditForm(name); values.forEach((valtuple) => { // Finds desired value based off given list - cy.get(`#${valtuple[0]}`).type(valtuple[1]); // of values and inserts the given number for the value + this.getSectionInput(valtuple[0]).type(valtuple[1]); }); // Clicks save button then waits until the desired config is visible, clicks it, // then checks that each desired value appears with the desired number cy.get('[data-testid=submitBtn]').click(); - cy.wait(3 * 1000); + cy.url().should('include', '#/configuration'); + cy.get(this.pages.index.id); // Enter config setting name into filter box this.searchTable(name, 100); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html index 13080dd7b2e..4c802c8fd07 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html @@ -1,164 +1,269 @@ -
-
-
-
- Edit {{ configForm.getValue('name') }} -
- -
- -
- -
- -
+ + +
+
+
+

Edit {{ configForm.getValue('name') }}

- -
- -
- -
+ +
+ + Name + +
-
- -
- -
+ @if (response?.long_desc) { +
+ +

{{ response.long_desc }}

+ } -
- -
- -
+ @if (configForm.getValue('default') !== '') { +
+ + Default + +
+ } -
- -
- -
+ @if (configForm.getValue('daemon_default') !== '') { +
+ + Daemon default + +
+ } -
- -
- - - {{ service }} - - + @if (configForm.getValue('services')?.length > 0) { +
+
+ +
+
+ @for (service of configForm.getValue('services'); track service) { + + {{ service }} + + }
+ }
-

Values

- -
- -
- -
-
+
+

Values

+
-
- -
- - - {{ patternHelpText }} - - - {{ patternHelpText }} - - The entered value is too high! It must not be greater than {{ maxValue }}. - The entered value is too low! It must not be lower than {{ minValue }}. -
+ @for (section of availSections; track section) { + @if (type === 'bool') { +
+ + + + + + + +
- + } + + @if (type !== 'bool' && inputType === 'number') { +
+ + + + + +
+ } + + @if (type !== 'bool' && inputType !== 'number') { +
+ + {{ section }} + + + + + +
+ } + } +
+ +
+
-
- -
-
+ + + @if (control?.invalid && (control.dirty || control.touched)) { + @if (control.hasError('min')) { + The entered value is too low! It must not be lower than {{ minValue }}. + } + @if (control.hasError('max')) { + The entered value is too high! It must not be greater than {{ maxValue }}. + } + @if (control.hasError('pattern') || control.hasError('invalidUuid')) { + {{ patternHelpText }} + } + } + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss index ed2945d1d56..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.scss @@ -1,12 +0,0 @@ -.form-component-badge { - display: block; - height: 34px; - - span { - margin-top: 7px; - } -} - -.resize-vertical { - resize: vertical; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts index f4a0557795a..c98f93dc93b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts @@ -141,8 +141,12 @@ export class ConfigurationFormComponent extends CdForm implements OnInit { this.availSections.forEach((section) => { const sectionValue = this.configForm.getValue(section); - if (sectionValue !== null) { + const hadValue = this.response?.value?.some((v) => v.section === section); + + if (sectionValue !== null && sectionValue !== undefined && sectionValue !== '') { values.push({ section: section, value: sectionValue }); + } else if (hadValue) { + values.push({ section: section, value: '' }); } }); @@ -188,8 +192,8 @@ export class ConfigurationFormComponent extends CdForm implements OnInit { this.configForm.setErrors({ cdSubmitButton: true }); } ); + } else { + this.router.navigate(['/configuration']); } - - this.router.navigate(['/configuration']); } } From d843b01385ece3362238cf9017ce417a1081f3dd Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Sun, 14 Jun 2026 20:43:50 +0800 Subject: [PATCH 410/596] test/encoding: run readable.sh with a sliding window of jobs The old scheduler spawned nproc jobs, then waited for the whole batch to finish before starting the next, so cores that finished early sat idle waiting on the slowest type in the batch. Keep a sliding window instead: once the window is full, reap a single finished job before spawning the next, which keeps the cores busy when per-type runtimes vary widely. While here, pass vdir/arversion into test_object explicitly instead of reading the loop globals, and give each job its own result file instead of reusing slot-numbered files that the sliding window would otherwise hand out to overlapping jobs. Signed-off-by: Sun Yuechi --- src/test/encoding/readable.sh | 93 +++++++++++++---------------------- 1 file changed, 35 insertions(+), 58 deletions(-) diff --git a/src/test/encoding/readable.sh b/src/test/encoding/readable.sh index 115d6d7b6f1..1a6fc3e6496 100755 --- a/src/test/encoding/readable.sh +++ b/src/test/encoding/readable.sh @@ -14,7 +14,6 @@ fi failed=0 numtests=0 -pids="" if [ -x ./ceph-dencoder ]; then CEPH_DENCODER=./ceph-dencoder @@ -36,7 +35,6 @@ if [ -z "$myversion" ]; then exit 1 fi DEBUG=0 -WAITALL_DELAY=.1 debug() { if [ "$DEBUG" -gt 0 ]; then echo "DEBUG: $*" >&2; fi } version_le() { @@ -67,7 +65,9 @@ versions_span() { test_object() { local type=$1 - local output_file=$2 + local vdir=$2 + local arversion=$3 + local output_file=$4 local failed=0 local numtests=0 @@ -134,6 +134,8 @@ test_object() { else echo "skipping forward incompat $type version $arversion, decoder >= $forward_version incompatible with objects < $forward_version (current decoder is $myversion)" fi + echo "failed=$failed" > $output_file + echo "numtests=$numtests" >> $output_file rm -f $tmp1 $tmp2 return fi @@ -146,6 +148,8 @@ test_object() { # Use sort -V for proper version comparison (handles 19.5 vs 19.10 correctly) if version_lt "$myversion" "$backward_incompat"; then echo "skipping backward incompat $type version $arversion, requires decoder >= $backward_incompat, current decoder is $myversion" + echo "failed=$failed" > $output_file + echo "numtests=$numtests" >> $output_file rm -f $tmp1 $tmp2 return fi @@ -240,52 +244,10 @@ test_object() { echo "numtests=$numtests" >> $output_file } -waitall() { # PID... - ## Wait for children to exit and indicate whether all exited with 0 status. - local errors=0 - while :; do - debug "Processes remaining: $*" - for pid in "$@"; do - shift - if kill -0 "$pid" 2>/dev/null; then - debug "$pid is still alive." - set -- "$@" "$pid" - elif wait "$pid"; then - debug "$pid exited with zero exit status." - else - debug "$pid exited with non-zero exit status." - errors=$(($errors + 1)) - fi - done - [ $# -eq 0 ] && break - sleep ${WAITALL_DELAY:-1} - done - [ $errors -eq 0 ] -} - ###### # MAIN ###### -do_join() { - waitall $pids - pids="" - # Reading the output of jobs to compute failed & numtests - # Tests are run in parallel but sum should be done sequentialy to avoid - # races between threads - while [ "$running_jobs" -ge 0 ]; do - if [ -f $output_file.$running_jobs ]; then - read_failed=$(grep "^failed=" $output_file.$running_jobs | cut -d "=" -f 2) - read_numtests=$(grep "^numtests=" $output_file.$running_jobs | cut -d "=" -f 2) - rm -f $output_file.$running_jobs - failed=$(($failed + $read_failed)) - numtests=$(($numtests + $read_numtests)) - fi - running_jobs=$(($running_jobs - 1)) - done - running_jobs=0 -} - # Determine the number of parallel jobs to run. Default to the number of # logical processors, or $MAX_PARALLEL_JOBS if set. if [ $(uname) == FreeBSD -o $(uname) == Darwin ]; then @@ -295,33 +257,48 @@ else fi max_parallel_jobs=${MAX_PARALLEL_JOBS:-${NPROC}} -output_file=$(mktemp /tmp/output_file-XXXXXXXXX) +# Sliding window of up to $max_parallel_jobs jobs, each writing its tally to its +# own $resultdir file; when full, reap one finished job before spawning the next. +resultdir=$(mktemp -d /tmp/readable-results-XXXXXXXXX) +trap 'rm -rf "$resultdir"' EXIT running_jobs=0 +jobid=0 for arversion in $(ls $dir/archive | sort -V); do vdir="$dir/archive/$arversion" - #echo $vdir if [ ! -d "$vdir/objects" ]; then continue; fi for type in $(ls $vdir/objects); do - test_object $type $output_file.$running_jobs & - pids="$pids $!" - running_jobs=$(($running_jobs + 1)) - - # Once we spawned enough jobs, let's wait them to complete - # Every spawned job have almost the same execution time so - # it's not a big deal having them not ending at the same time - if [ "$running_jobs" -eq "$max_parallel_jobs" ]; then - do_join + if [ "$running_jobs" -ge "$max_parallel_jobs" ]; then + wait -n || true + running_jobs=$(($running_jobs - 1)) fi - rm -f ${output_file}* + test_object "$type" "$vdir" "$arversion" "$resultdir/$jobid" & + running_jobs=$(($running_jobs + 1)) + jobid=$(($jobid + 1)) done done -do_join +# wait for the remaining in-flight jobs +wait + +# Sum results sequentially. A missing result file means the job died before +# recording its tally, so count it as a failure rather than skipping it. +for ((i = 0; i < jobid; i++)); do + f="$resultdir/$i" + if [ ! -f "$f" ]; then + echo "**** result file for job $i is missing; the job likely died before recording its result ****" + failed=$(($failed + 1)) + continue + fi + read_failed=$(grep "^failed=" "$f" | cut -d "=" -f 2) + read_numtests=$(grep "^numtests=" "$f" | cut -d "=" -f 2) + failed=$(($failed + $read_failed)) + numtests=$(($numtests + $read_numtests)) +done if [ $failed -gt 0 ]; then echo "FAILED $failed / $numtests tests." From 0077cb990dd6ea11fbed86be6e7449d8f035e2d0 Mon Sep 17 00:00:00 2001 From: Avan Thakkar Date: Wed, 24 Jun 2026 16:45:51 +0530 Subject: [PATCH 411/596] mgr: fix MgrModuleRecoverDB to honor its retry budget on db cleanup failure Both close_db() and open_db() during retry were unguarded, so a failure in either escaped the decorator immediately instead of retrying up to MAX_DBCLEANUP_RETRIES. Wrapped both in the same try/except, and logged the failed attempt. Fixes: https://tracker.ceph.com/issues/77635 Signed-off-by: Avan Thakkar --- src/pybind/mgr/mgr_module.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/mgr_module.py b/src/pybind/mgr/mgr_module.py index 192eefbf96e..3ba6cb3277c 100644 --- a/src/pybind/mgr/mgr_module.py +++ b/src/pybind/mgr/mgr_module.py @@ -619,8 +619,13 @@ def MgrModuleRecoverDB(func: Callable) -> Callable: if retries > MAX_DBCLEANUP_RETRIES: raise self.log.debug("attempting reopen of database") - self.close_db() - self.open_db() + try: + self.close_db() + self.open_db() + except sqlite3.DatabaseError as e2: + self.log.warning( + f"reopen attempt {retries}/{MAX_DBCLEANUP_RETRIES} failed: {e2}" + ) # allow retry of func(...) check.__signature__ = inspect.signature(func) # type: ignore[attr-defined] return check From e9bf02b72b047d2c8c9ec7ff611fdec96b7cc995 Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Thu, 11 Jun 2026 12:32:34 +0000 Subject: [PATCH 412/596] crimson/osd: return ENOENT from EC getxattr for non-existent objects PGBackend::getxattr() for EC pools reads from the attr_cache and returns ENODATA when an attr is not found, without distinguishing between "object exists but attr not set" and "object doesn't exist". This causes cls methods like cls_refcount_get() to proceed as if the object exists, enter the EC RMW pipeline, and hang. Add an os.exists check in the EC path so non-existent objects return ENOENT, matching the behavior of the replicated path which goes to the object store and gets ENOENT directly. Fixes: https://tracker.ceph.com/issues/77335 Signed-off-by: Ronen Friedman --- src/crimson/osd/pg_backend.cc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/crimson/osd/pg_backend.cc b/src/crimson/osd/pg_backend.cc index 6a2e57d0992..0c31e28f333 100644 --- a/src/crimson/osd/pg_backend.cc +++ b/src/crimson/osd/pg_backend.cc @@ -1170,6 +1170,10 @@ PGBackend::get_attr_ierrorator::future<> PGBackend::getxattr( logger().debug("getxattr on obj={} for attr={}", os.oi.soid, name); return crimson::ct_error::enodata::make(); }; + if (is_erasure() && !os.exists) { + logger().debug("getxattr: object {} does not exist (erasure)", os.oi.soid); + return crimson::ct_error::enoent::make(); + } return get_attr_maybe_from_cache().safe_then_interruptible( [&delta_stats, &osd_op] (ceph::bufferlist&& val) { osd_op.outdata = std::move(val); From 06eeede4cbf935d3bfabdb45d98b11b2f1ac87ec Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Thu, 18 Jun 2026 17:34:37 +0000 Subject: [PATCH 413/596] cls/rgw: clarify RGWObjCategory::Shadow comment The Shadow enum value (2) is never assigned anywhere in the codebase. Update the comment to clearly state it is unused and reserved for backward compatibility, since the value is serialized as a raw uint8_t in bucket index entries and may exist in on-disk data. Signed-off-by: Oguzhan Ozmen --- src/cls/rgw/cls_rgw_types.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cls/rgw/cls_rgw_types.h b/src/cls/rgw/cls_rgw_types.h index caea0407061..2e0fe2fed59 100644 --- a/src/cls/rgw/cls_rgw_types.h +++ b/src/cls/rgw/cls_rgw_types.h @@ -189,8 +189,7 @@ enum class RGWObjCategory : uint8_t { Main = 1, // b-i entries for standard objs - Shadow = 2, // presumably intended for multipart shadow - // uploads; not currently used in the codebase + Shadow = 2, // unused, reserved for backward compatibility MultiMeta = 3, // b-i entries for multipart upload metadata objs From 93e763cee9b1f0a6089b44d35a56092ef7f9b040 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Thu, 18 Jun 2026 17:55:45 +0000 Subject: [PATCH 414/596] rgw: add RGWObjCategory::MultiPart for multipart upload part heads Multipart upload part head objects are bucket-indexed with RGWObjCategory::Main, making them indistinguishable from completed objects in bucket stats. This is causing radosgw-admin bucket stats and the RGW-extension X-RGW-Object-Count header on HEAD Bucket (?read-stats=true) to report inflated object counts when in-progress or abandoned multipart uploads exists. So, this commit adds a new Object Category: RGWObjCategory::MultiPart (value 5) and assigns it in MultipartObjectProcessor::complete() so part heads are now tracked under a separate "rgw.multipart" category. The X-RGW-Object-Count header, which reads only RGWObjCategory::Main, now excludes in-progress parts. This is more correct as AWS S3 HEAD Bucket does not expose any object count for incomplete uploads (the header itself seems to be an RGW extension, not part of the S3 specification). Quota enforcement, stats aggregation, and part cleanup are all category-agnostic so should require no changes. Also, updated the test_cls_rgw_stats simulator to use the new category for multipart part entries. Fixes: https://tracker.ceph.com/issues/77509 Signed-off-by: Oguzhan Ozmen --- src/cls/rgw/cls_rgw_types.cc | 1 + src/cls/rgw/cls_rgw_types.h | 2 ++ src/rgw/driver/rados/rgw_putobj_processor.cc | 1 + src/test/cls_rgw/test_cls_rgw_stats.cc | 2 +- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cls/rgw/cls_rgw_types.cc b/src/cls/rgw/cls_rgw_types.cc index 7f8835bd938..5ef9633a84e 100644 --- a/src/cls/rgw/cls_rgw_types.cc +++ b/src/cls/rgw/cls_rgw_types.cc @@ -140,6 +140,7 @@ std::string_view to_string(RGWObjCategory c) case RGWObjCategory::Shadow: return "rgw.shadow"; case RGWObjCategory::MultiMeta: return "rgw.multimeta"; case RGWObjCategory::CloudTiered: return "rgw.cloudtiered"; + case RGWObjCategory::MultiPart: return "rgw.multipart"; default: return "unknown"; } } diff --git a/src/cls/rgw/cls_rgw_types.h b/src/cls/rgw/cls_rgw_types.h index 2e0fe2fed59..bbab3306cb1 100644 --- a/src/cls/rgw/cls_rgw_types.h +++ b/src/cls/rgw/cls_rgw_types.h @@ -194,6 +194,8 @@ enum class RGWObjCategory : uint8_t { MultiMeta = 3, // b-i entries for multipart upload metadata objs CloudTiered = 4, // b-i entries which are tiered to external cloud + + MultiPart = 5, // b-i entries for multipart upload part head objs }; std::string_view to_string(RGWObjCategory c); diff --git a/src/rgw/driver/rados/rgw_putobj_processor.cc b/src/rgw/driver/rados/rgw_putobj_processor.cc index 3292e1c4e97..9eee7a5faf3 100644 --- a/src/rgw/driver/rados/rgw_putobj_processor.cc +++ b/src/rgw/driver/rados/rgw_putobj_processor.cc @@ -557,6 +557,7 @@ int MultipartObjectProcessor::complete( obj_op.meta.mtime = mtime; obj_op.meta.owner = owner; obj_op.meta.bucket_owner = bucket_info.owner; + obj_op.meta.category = RGWObjCategory::MultiPart; obj_op.meta.delete_at = delete_at; obj_op.meta.zones_trace = zones_trace; obj_op.meta.modify_tail = true; diff --git a/src/test/cls_rgw/test_cls_rgw_stats.cc b/src/test/cls_rgw/test_cls_rgw_stats.cc index 1c12367ec03..26ba529259e 100644 --- a/src/test/cls_rgw/test_cls_rgw_stats.cc +++ b/src/test/cls_rgw/test_cls_rgw_stats.cc @@ -552,7 +552,7 @@ void simulator::complete_multipart(const operation& op) << " size=" << part_size << dendl; } else { rgw_bucket_dir_entry_meta meta; - meta.category = op.meta.category; + meta.category = RGWObjCategory::MultiPart; meta.size = meta.accounted_size = part_size; int r = index_complete(ioctx, oid, part_key, op.tag, op.type, From 29099f910cd09915b9e887d9560a9961e8b3cd3d Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Thu, 18 Jun 2026 18:18:44 +0000 Subject: [PATCH 415/596] qa/rgw: add integration test for multipart part category in bucket stats Test that multipart upload part heads are tracked under the rgw.multipart category in radosgw-admin bucket stats, separate from completed objects (rgw.main). - Incomplete upload: rgw.main=0, rgw.multipart=X, rgw.multimeta=1 - CompleteMultipart: parts cleaned up, assembled object in rgw.main - AbortMultipart: parts cleaned up, rgw.multipart returns to 0 - ListObjects returns 0 during incomplete upload Also add quota enforcement testcase for multipart part category - Verify that multipart upload parts tracked under RGWObjCategory::MultiPart still count against both bucket and user quotas. Tests: https://tracker.ceph.com/issues/77509 Signed-off-by: Oguzhan Ozmen --- .../rgw/test_rgw_multipart_category.py | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 qa/workunits/rgw/test_rgw_multipart_category.py diff --git a/qa/workunits/rgw/test_rgw_multipart_category.py b/qa/workunits/rgw/test_rgw_multipart_category.py new file mode 100644 index 00000000000..dd0c3d7828f --- /dev/null +++ b/qa/workunits/rgw/test_rgw_multipart_category.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +import logging as log +import json +from common import exec_cmd, create_user, boto_connect +from botocore.config import Config +from botocore.exceptions import ClientError + +""" +Tests that multipart upload part heads are tracked under the +rgw.multipart category in bucket stats, separate from completed +objects (rgw.main), and that quota enforcement still counts them. +""" + +USER = 'mpart-cat-tester' +DISPLAY_NAME = 'Multipart Category Testing' +ACCESS_KEY = 'MPARTCAT1TESTKEY00001' +SECRET_KEY = 'mpartcat1testsecretkey000000001' + +PART_SIZE = 5 * 1024 * 1024 # 5 MiB (S3 minimum part size) + + +def get_bucket_stats(bucket_name): + out = exec_cmd(f'radosgw-admin bucket stats --bucket {bucket_name}') + return json.loads(out) + + +def get_category(stats, category): + return stats['usage'].get(category, {}) + + +def assert_category(stats, category, num_objects, label=''): + cat = get_category(stats, category) + actual = cat.get('num_objects', 0) + assert actual == num_objects, \ + f'{label}: expected {category}.num_objects={num_objects}, got {actual}' + + +def cleanup_bucket(connection, bucket_name): + try: + s3client = connection.meta.client + response = s3client.list_multipart_uploads(Bucket=bucket_name) + for upload in response.get('Uploads', []): + s3client.abort_multipart_upload( + Bucket=bucket_name, Key=upload['Key'], + UploadId=upload['UploadId']) + connection.Bucket(bucket_name).objects.all().delete() + connection.Bucket(bucket_name).delete() + except Exception: + pass + + +def test_category_tracking(connection): + """Verify multipart parts appear under rgw.multipart, not rgw.main.""" + bucket_name = 'mpart-cat-bucket' + cleanup_bucket(connection, bucket_name) + connection.create_bucket(Bucket=bucket_name) + s3client = connection.meta.client + + # TESTCASE 'empty bucket has no stats' + log.debug('TEST: empty bucket has no stats\n') + stats = get_bucket_stats(bucket_name) + assert_category(stats, 'rgw.main', 0, 'empty bucket') + + # TESTCASE 'incomplete multipart parts appear under rgw.multipart' + log.debug('TEST: incomplete multipart parts appear under rgw.multipart\n') + part_body = b'x' * PART_SIZE + + response = s3client.create_multipart_upload(Bucket=bucket_name, Key='incomplete-obj') + upload_id = response['UploadId'] + + for part_num in (1, 2): + s3client.upload_part( + Bucket=bucket_name, Key='incomplete-obj', + UploadId=upload_id, PartNumber=part_num, Body=part_body) + + stats = get_bucket_stats(bucket_name) + assert_category(stats, 'rgw.main', 0, 'after upload parts') + assert_category(stats, 'rgw.multipart', 2, 'after upload parts') + assert_category(stats, 'rgw.multimeta', 1, 'after upload parts') + + response = s3client.list_objects_v2(Bucket=bucket_name) + assert response['KeyCount'] == 0, f'expected 0 listed objects, got {response["KeyCount"]}' + + # TESTCASE 'complete multipart moves parts to rgw.main' + log.debug('TEST: complete multipart moves parts to rgw.main\n') + parts_list = s3client.list_parts( + Bucket=bucket_name, Key='incomplete-obj', UploadId=upload_id) + parts = [{'ETag': p['ETag'], 'PartNumber': p['PartNumber']} + for p in parts_list['Parts']] + + s3client.complete_multipart_upload( + Bucket=bucket_name, Key='incomplete-obj', + UploadId=upload_id, + MultipartUpload={'Parts': parts}) + + stats = get_bucket_stats(bucket_name) + assert_category(stats, 'rgw.main', 1, 'after complete') + assert_category(stats, 'rgw.multipart', 0, 'after complete') + assert_category(stats, 'rgw.multimeta', 0, 'after complete') + + # TESTCASE 'abort multipart cleans up rgw.multipart entries' + log.debug('TEST: abort multipart cleans up rgw.multipart entries\n') + response = s3client.create_multipart_upload( + Bucket=bucket_name, Key='aborted-obj') + upload_id2 = response['UploadId'] + + s3client.upload_part( + Bucket=bucket_name, Key='aborted-obj', + UploadId=upload_id2, PartNumber=1, Body=part_body) + + stats = get_bucket_stats(bucket_name) + assert_category(stats, 'rgw.main', 1, 'before abort') + assert_category(stats, 'rgw.multipart', 1, 'before abort') + assert_category(stats, 'rgw.multimeta', 1, 'before abort') + + s3client.abort_multipart_upload(Bucket=bucket_name, Key='aborted-obj', UploadId=upload_id2) + + stats = get_bucket_stats(bucket_name) + assert_category(stats, 'rgw.main', 1, 'after abort') + assert_category(stats, 'rgw.multipart', 0, 'after abort') + assert_category(stats, 'rgw.multimeta', 0, 'after abort') + + cleanup_bucket(connection, bucket_name) + log.debug('test_category_tracking PASSED\n') + + +def test_quota_enforcement(connection): + """Verify multipart parts count against bucket and user quotas.""" + bucket1 = 'mpart-quota-1' + bucket2 = 'mpart-quota-2' + s3client = connection.meta.client + part_body = b'x' * PART_SIZE + + cleanup_bucket(connection, bucket1) + cleanup_bucket(connection, bucket2) + + # bucket quota: 15 MB (3 x 5MB parts), user quota: 25 MB (5 x 5MB parts) + exec_cmd(f'radosgw-admin quota set --quota-scope=bucket --uid={USER} --max-size=15728640') + exec_cmd(f'radosgw-admin quota set --quota-scope=user --uid={USER} --max-size=26214400') + exec_cmd(f'radosgw-admin quota enable --quota-scope=bucket --uid={USER}') + exec_cmd(f'radosgw-admin quota enable --quota-scope=user --uid={USER}') + + try: + connection.create_bucket(Bucket=bucket1) + connection.create_bucket(Bucket=bucket2) + + # TESTCASE 'bucket quota: 4th part exceeds 15 MB bucket limit' + log.debug('TEST: bucket quota rejects part that exceeds limit\n') + mpu1 = s3client.create_multipart_upload(Bucket=bucket1, Key='obj1') + uid1 = mpu1['UploadId'] + + for i in range(1, 4): + s3client.upload_part(Bucket=bucket1, Key='obj1', UploadId=uid1, + PartNumber=i, Body=part_body) + + try: + s3client.upload_part(Bucket=bucket1, Key='obj1', UploadId=uid1, + PartNumber=4, Body=part_body) + assert False, 'part 4 should have been rejected by bucket quota' + except ClientError as e: + assert e.response['Error']['Code'] == 'QuotaExceeded', \ + f'expected QuotaExceeded, got {e.response["Error"]["Code"]}' + + response = s3client.list_objects_v2(Bucket=bucket1) + assert response['KeyCount'] == 0, 'bucket1 should have no completed objects' + + stats = get_bucket_stats(bucket1) + assert_category(stats, 'rgw.main', 0, 'bucket1 quota') + assert_category(stats, 'rgw.multipart', 3, 'bucket1 quota') + + # TESTCASE 'user quota: 3rd part in bucket2 exceeds 25 MB user limit' + log.debug('TEST: user quota rejects part that exceeds limit\n') + mpu2 = s3client.create_multipart_upload(Bucket=bucket2, Key='obj2') + uid2 = mpu2['UploadId'] + + # 15 MB already used in bucket1; user quota is 25 MB + for i in range(1, 3): + s3client.upload_part(Bucket=bucket2, Key='obj2', UploadId=uid2, + PartNumber=i, Body=part_body) + + try: + s3client.upload_part(Bucket=bucket2, Key='obj2', UploadId=uid2, + PartNumber=3, Body=part_body) + assert False, 'part 3 should have been rejected by user quota' + except ClientError as e: + assert e.response['Error']['Code'] == 'QuotaExceeded', \ + f'expected QuotaExceeded, got {e.response["Error"]["Code"]}' + + response = s3client.list_objects_v2(Bucket=bucket2) + assert response['KeyCount'] == 0, 'bucket2 should have no completed objects' + + stats = get_bucket_stats(bucket2) + assert_category(stats, 'rgw.main', 0, 'bucket2 quota') + assert_category(stats, 'rgw.multipart', 2, 'bucket2 quota') + + finally: + cleanup_bucket(connection, bucket1) + cleanup_bucket(connection, bucket2) + exec_cmd(f'radosgw-admin quota disable --quota-scope=bucket --uid={USER}') + exec_cmd(f'radosgw-admin quota disable --quota-scope=user --uid={USER}') + + log.debug('test_quota_enforcement PASSED\n') + + +def main(): + create_user(USER, DISPLAY_NAME, ACCESS_KEY, SECRET_KEY) + + # ensure quotas are disabled before category tracking tests + exec_cmd(f'radosgw-admin quota disable --quota-scope=bucket --uid={USER}') + exec_cmd(f'radosgw-admin quota disable --quota-scope=user --uid={USER}') + + connection = boto_connect(ACCESS_KEY, SECRET_KEY, Config(retries={ + 'total_max_attempts': 1, + })) + + test_category_tracking(connection) + test_quota_enforcement(connection) + + log.debug('All multipart category tests passed\n') + + +if __name__ == '__main__': + main() From c429d2d15da53c614fdbf099588a14546416d221 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 24 Jun 2026 16:49:40 +0000 Subject: [PATCH 416/596] rgw/s3-tests: test HEAD bucket object count excludes incomplete multipart parts Add test_head_bucket_usage_multipart_incomplete to verify that X-RGW-Object-Count (returned by HEAD Bucket with ?read-stats=true) does not count in-progress multipart upload parts as completed objects. After uploading 2 parts without completing, HEAD bucket should report 0 objects and ListObjectsV2 should return 0 keys. Tests: https://tracker.ceph.com/issues/77509 Signed-off-by: Oguzhan Ozmen --- .../s3-tests/s3tests/functional/test_s3.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/test/rgw/s3-tests/s3tests/functional/test_s3.py b/src/test/rgw/s3-tests/s3tests/functional/test_s3.py index 9878c248606..6dd04d5a2ad 100644 --- a/src/test/rgw/s3-tests/s3tests/functional/test_s3.py +++ b/src/test/rgw/s3-tests/s3tests/functional/test_s3.py @@ -1127,6 +1127,40 @@ def test_head_bucket_usage(): assert hdrs['X-RGW-Bytes-Used'] == '3' assert hdrs['X-RGW-Quota-Max-Buckets'] == '1000' +@pytest.mark.fails_on_aws +@pytest.mark.fails_on_dbstore +def test_head_bucket_usage_multipart_incomplete(): + bucket_name = get_new_bucket() + client = get_client() + + # initiate a multipart upload and upload 2 parts without completing + response = client.create_multipart_upload(Bucket=bucket_name, Key='mpu-obj') + upload_id = response['UploadId'] + part_body = 'x' * (5 * 1024 * 1024) + for part_num in (1, 2): + client.upload_part(Bucket=bucket_name, Key='mpu-obj', + UploadId=upload_id, PartNumber=part_num, + Body=part_body) + + # HEAD bucket with read-stats should show 0 objects: incomplete + # multipart parts are not counted as user-visible objects. + def add_read_stats_param(request, **kwargs): + request.params['read-stats'] = 'true' + + client.meta.events.register('request-created.s3.HeadBucket', add_read_stats_param) + client.meta.events.register('after-call.s3.HeadBucket', get_http_response) + client.head_bucket(Bucket=bucket_name) + hdrs = http_response['headers'] + assert hdrs['X-RGW-Object-Count'] == '0' + + # ListObjects should also return nothing + response = client.list_objects_v2(Bucket=bucket_name) + assert response['KeyCount'] == 0 + + # cleanup + client.abort_multipart_upload(Bucket=bucket_name, Key='mpu-obj', + UploadId=upload_id) + @pytest.mark.fails_on_aws @pytest.mark.fails_on_dbstore def test_bucket_list_unordered(): From 78b5f05d00d1e55bd212f3b994048432fbba7d3c Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Thu, 18 Jun 2026 18:22:17 +0000 Subject: [PATCH 417/596] doc: add PendingReleaseNotes entry for the new rgw multipart category Signed-off-by: Oguzhan Ozmen --- PendingReleaseNotes | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/PendingReleaseNotes b/PendingReleaseNotes index 348fa282cc6..e1b1da5eb46 100644 --- a/PendingReleaseNotes +++ b/PendingReleaseNotes @@ -1037,3 +1037,13 @@ can be configured using the `rgw` option `rgw_bucket_persistent_notif_num_shards https://docs.ceph.com/en/latest/radosgw/notifications/ Relevant tracker: https://tracker.ceph.com/issues/71677 + +* RGW: Multipart upload part heads are now tracked under a new + ``rgw.multipart`` category in bucket stats, separate from completed + objects (``rgw.main``). This makes ``radosgw-admin bucket stats`` + and the ``X-RGW-Object-Count`` header on HEAD Bucket more accurate: + in-progress or abandoned multipart parts no longer inflate the + object count. Existing in-progress uploads retain the old category + until they complete or abort. + + Relevant tracker: https://tracker.ceph.com/issues/77509 From 9061bc4a4448d23b3f9f9bb1d00717a07a2d95d3 Mon Sep 17 00:00:00 2001 From: Adam Kupczyk Date: Fri, 19 Jun 2026 15:02:16 +0200 Subject: [PATCH 418/596] doc/rados/bluestore: ExtBlkDev, FCM plugin Add new page about 2 topics: - EXTBLKDEV - FCM plugin Signed-off-by: Adam Kupczyk --- doc/rados/bluestore/fcm-plugin.rst | 303 +++++++++++++++++++++++++++++ doc/rados/configuration/index.rst | 1 + 2 files changed, 304 insertions(+) create mode 100644 doc/rados/bluestore/fcm-plugin.rst diff --git a/doc/rados/bluestore/fcm-plugin.rst b/doc/rados/bluestore/fcm-plugin.rst new file mode 100644 index 00000000000..52076be7604 --- /dev/null +++ b/doc/rados/bluestore/fcm-plugin.rst @@ -0,0 +1,303 @@ +======================= + EXTBLKDEV, FCM plugin +======================= + +.. index:: bluestore; extblkdev + +ExtBlkDev +========= + +With Pacific release Ceph was extended to handle thinly-provisioned and compressing drives. +The extension is made in form of loadable plugins that are part of Ceph codebase: ``src/extblkdev``. + +At startup OSD checks if specific plugins are enabled, as controlled by: + +.. confval:: osd_extblkdev_plugins + +To let OSD load and check for presence of FCM devices set: + +.. code-block:: + + osd_extblkdev_plugins = fcm + + +Mode of operation +----------------- + +Thin-provisioned and compressing drives have different capacity for data than what is advertised +as addressable logical space. It means that a compressing drive might advertise its size as 10TB, +but in reality only operate on 2TB of physical NAND. +ExtBlkDev plugins provide an additional information channel between devices and BlueStore, +giving Ceph report current disk logical / physical, used / available states. + +This information is critical for OSDs: they might otherwise try to write more data to the device +than it can store, which will critically fail when it is really REALLY full. +Without current and accurate drive usage the configurables are useless: + +.. confval:: mon_osd_full_ratio + +.. confval:: mon_osd_nearfull_ratio + +The ExtBlkDev extension is implemented on ``BlockDevice`` level, common for ``Main``, ``DB``, and ``WAL`` devices. +However, BlueStore only uses it in context of ``Main`` device. +For the ``DB`` and ``WAL`` devices, the plugin is detected and loaded, but BlueStore does not interact with it. + +Plugin persistence +------------------ + +.. note:: + + The examples below are derived from an actual deployment of the FCM plugin. + +Having the proper plugin loaded is critical to OSD operation. + +.. prompt:: # bash + + ceph osd df + +.. code-block:: + + ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS + 0 ssd 5.07739 1.00000 31 TiB 4.3 TiB 4.2 TiB 11 KiB 12 GiB 26 TiB 13.92 0.77 129 up + 1 ssd 5.07739 1.00000 31 TiB 4.3 TiB 4.2 TiB 12 KiB 12 GiB 26 TiB 13.93 0.77 128 up + 2 ssd 5.07739 1.00000 31 TiB 4.3 TiB 4.2 TiB 12 KiB 12 GiB 26 TiB 13.93 0.77 129 up + 3 ssd 5.07739 1.00000 31 TiB 4.2 TiB 4.2 TiB 12 KiB 12 GiB 26 TiB 13.90 0.77 128 up + 4 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 4.2 TiB 12 KiB 12 GiB 2.9 TiB 43.08 2.38 128 up + 5 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 4.3 TiB 12 KiB 12 GiB 2.9 TiB 43.25 2.39 128 up + 6 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 4.2 TiB 12 KiB 12 GiB 2.9 TiB 43.06 2.38 128 up + 7 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 4.3 TiB 14 KiB 12 GiB 2.9 TiB 43.44 2.40 129 up + TOTAL 143 TiB 26 TiB 34 TiB 101 KiB 94 GiB 117 TiB 18.09 1027 + MIN/MAX VAR: 0.77/2.40 STDDEV: 18.00 + +In the above case OSDs 0-3 run without the plugin; OSDs 4-7 have the plugin loaded and active. +All eight OSDs participate in a single 6+2 erasure coded pool and therefore carry equivalent utilization. +With EC6+2 this is moot, but for other pool types the balancer will prioritize OSDs 0-3 which seem to be mostly empty +trying to offload seemingly overburdened OSDs 4-7. + +Tentacle provides a new configurable that signals BlueStore to expect that an ExtBlkDev plugin will be in use. + +.. confval:: bluestore_use_ebd + +The ExtBlkDev infrastructure adds new category of health warnings: + +.. code-block:: + + HEALTH_WARN 8 OSD(s) reporting problems with ExtBlkDev plugin; + [WRN] EXTBLKDEV: 8 OSD(s) reporting problems with ExtBlkDev plugin + + +With the ``bluestore_use_ebd`` set to ``true``, two EXTBLKDEV health warnings may appear: + + 1) "plugin 'fcm' not loaded" + + 2) "plugin 'fcm' used on mkfs, but now uses plugin 'disabled'" + + +Having the configuration option set to ``true`` does not require that a plugin is used. +If a plugin is not found during OSD creation, this is still a proper and acceptable state. +However, once a plugin is detected during the OSD's creation, its usage becomes mandatory. +Should plugin presence be required for specific OSD, one can check for it: + +.. prompt:: bash # + + ceph osd metadata osd.2 + +.. code-block:: + + ... + "bluestore_bdev_fcm": "true", + ... + +or + +.. prompt:: bash # + + ceph-bluestore-tool --path dev/osd4/ show-label + +.. code-block:: + + ... + "extblkdev": "fcm", + ... + +The first check is somewhat weaker than the second. +In a specific error case that the plugin was detected and memorized at OSD creation, +but is not loaded, one will not see the value in OSD metadata. Instead a health warning appears. +The second check never fails. It simply stores the setting in BlueStore label metadata. + +.. _fix-use-ebd: + +A fix +----- + +If ``bluestore_use_ebd=true`` was not set when OSDs were created on devices that should run with a plugin, +it may be set after the fact with a command of the following form: + +.. prompt:: bash # + + ceph-bluestore-tool --dev dev/osd4/block -k extblkdev -v fcm set-label-key + +.. _fcm-plugin: + +FCM plugin +========== + +Ceph releases beginning with Tentacle provide a plugin to operate with FlashCoreModule devices (FCMs). +Like all ExtBlkDev plugins, it reports true disk usage to BlueStore, effectively overriding +BlueStore reported metrics of used and available device space. +FCM reported metrics are in physical NAND size, not in logical space size. + +In addition the plugin reports fixed stats via OSD metadata: + +.. prompt:: bash # + + ceph osd metadata osd.2 + +.. code-block:: + + ... + "bluestore_bdev_fcm_device_logical_size": "33599931809792", + "bluestore_bdev_fcm_device_physical_size": "5582606303232", + "bluestore_bdev_fcm_partition_logical_size": "33599931809792", + "bluestore_bdev_fcm_partition_physical_size": "5582606303232", + ... + +And dynamic current state via performance counters: + +.. prompt:: bash # + + ceph tell osd.6 perf dump extblkdev + +.. code-block:: + + { + "extblkdev": { + "fcm": 0, <- value is irrelevant, it is the name that counts + "dev_phy_size": 5582606303232, + "dev_log_size": 33599931809792, + "dev_phy_util": 2403758935846, + "dev_log_util": 4714543366144, + "part_phy_size": 5582606303232, + "part_log_size": 33599931809792, + "part_phy_avail": 3178847367386, + "part_log_avail": 28885388443648 + } + } + +The FCM plugin provides three new health warnings in the EXTBLKDEV class. + + 1) "failed accessing FCM utilization log" :ref:`fcm-permissions` + + 2) "bdev_enable_discard not enabled - free space will leak" :ref:`fcm-discard` + + 3) "multivolume fcm will not work properly" :ref:`fcm-multivolume` + + +.. _fcm-permissions: + +FCM permissions +--------------- + +The FCM plugin talks to the NVMe controller within each storage device. +In Linux, this is a privileged operation, tied to the `cap_sys_admin` privilege. +To get the privilege do either: + + a) Run Ceph OSD as root. + + b) Grant ``setcap "cap_sys_admin=p" ceph-osd`` + and run with ``ceph-osd --set-keepcaps=true`` . + +.. _fcm-discard: + +FCM discard +----------- + +FCM devices implement compression; their physical NAND capacity is much smaller than their logical device size. +Conventional SSDs storing data that is no longer used by BlueStore experience may experience suboptimal performance. +With FCMs, orphaned data represents a direct loss of available capacity. To prevent this problem, one should always set: + +.. code-block:: + + bdev_enable_discard = true + +.. _fcm-multivolume: + +FCM multivolume +--------------- + +BlueStore works well when there is a one-to-one mapping between FCM devices to BlueStore block devices. + +Splitting an FCM device in half by partitioning does not create any boundary for the controller. +Filling one partition will affect available space in the other partition. +Two partitions together will have the total logical capacity exactly the same, but available physical NAND will be counted twice. +This will most likely confuse both balancers and operators. + + NOTE: The FCM plugin does not detect this condition. + +Combining multiple FCM devices using the device mapper, LVM, or Linux MD software RAID is not recommended. When doing so, +BlueStore has no visibility of the topology of the backing block devices and cannot know to address storage by specific logical device offsets. +For regular drives it does not matter; when the physical:logical ratio is 1:1 BlueStore will eventually run out of logical space on first drive +and without even noticing start consuming the second drive. +With FCMs, and any other compressing drives, it is no longer the case. When BlueStore is not running out of logical space on first drive, +it will keep asking the drive to accomodate more data. The second drive has it, but first does not. +BlueStore will eventually fail to allocate space and fail with a hard ``-ENOSPC`` error. + +Trying to run BlueStore with compound block device when one of them is an FCM will raise the warning. +An OSD cannot operate long-term in this way. It must be redeployed. + + + +Redeploy and discard +-------------------- + +When one redeploys an FCM OSD without first formatting or issuing a whole-device discard operation, +BlueStore will be logically empty, but the underlying device will still hold the burden of previously held data. + +.. code-block:: + + ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS + 0 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 956 KiB 24 KiB 26 MiB 2.9 TiB 43.16 1.00 1 up + 1 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 376 KiB 20 KiB 26 MiB 2.9 TiB 43.14 1.00 0 up + 2 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 956 KiB 20 KiB 26 MiB 2.9 TiB 43.15 1.00 1 up + 3 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 376 KiB 16 KiB 26 MiB 2.9 TiB 43.05 1.00 0 up + 4 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 376 KiB 14 KiB 26 MiB 2.9 TiB 43.08 1.00 0 up + 5 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 376 KiB 11 KiB 26 MiB 2.9 TiB 43.24 1.00 0 up + 6 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 376 KiB 7 KiB 26 MiB 2.9 TiB 43.05 1.00 0 up + 7 ssd 5.07739 1.00000 5.1 TiB 2.2 TiB 956 KiB 6 KiB 26 MiB 2.9 TiB 43.44 1.01 1 up + TOTAL 41 TiB 18 TiB 4.6 MiB 121 KiB 210 MiB 23 TiB 43.17 3 + MIN/MAX VAR: 1.00/1.01 STDDEV: 0.12 + +Solution: + + a) ``nvme format`` before deploying + b) Set ``bluestore_discard_on_mkfs=true`` (Umbrella+). + + +CRUSH weight +------------ + +This section describes :ref:`fix-use-ebd` when using the FCM plugin :ref:`fcm-plugin`. + +When an OSD is properly deployed on an FCM, the WEIGHT and SIZE values reflect the physical NAND capacity: + +.. code-block:: + + ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS + 0 ssd 5.07739 1.00000 5.1 TiB 12 MiB 944 KiB 21 KiB 26 MiB 5.1 TiB 0 1.00 1 up + +When an OSD is deployed without the active FCM plugin, the WEIGHT and SIZE reflect logical advertised values: + +.. code-block:: + + ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS + 0 ssd 30.55899 1.00000 31 TiB 27 MiB 908 KiB 16 KiB 26 MiB 31 TiB 0 1.01 1 up + +When a plugin is active for the underlying device, the result is that the SIZE +is the proper physical NAND capacity, but the CRUSH weight is still locked to original value at deployment: + +.. code-block:: + + ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS + 0 ssd 30.55899 1.00000 5.1 TiB 28 KiB 1.0 MiB 7 KiB 27 MiB 5.1 TiB 0 1.42 1 up + +The CRUSH weight of such OSDs must be adjusted. diff --git a/doc/rados/configuration/index.rst b/doc/rados/configuration/index.rst index 0367b449416..985858a8e62 100644 --- a/doc/rados/configuration/index.rst +++ b/doc/rados/configuration/index.rst @@ -23,6 +23,7 @@ For general object store configuration, refer to the following: BlueStore RocksDB cache <../bluestore/rocksdb-config> BlueFS Spillover Cleaner <../bluestore/bluefs-spillover-cleaner> Fast Crash Recovery for file-stored allocations <../bluestore/fast-onode-scan> + ExtBlkDev, FCM plugin <../bluestore/fcm-plugin> ceph-conf From 5a77c4fd359e485501256fa3b21272fb06e8f0af Mon Sep 17 00:00:00 2001 From: Miki Patel Date: Tue, 21 Apr 2026 17:23:29 +0530 Subject: [PATCH 419/596] rbd-mirror: Remove old non-primary demoted image snapshots on the local cluster When an image is demoted on primary cluster and later promoted again, a non-primary demoted image snapshot is created on the peer (secondary) cluster. Each such promote/demote cycle on same cluster results in an additional non-primary demoted snapshot being created on peer. As a result, repeated promote/demote cycles on the same cluster can lead to accumulation of stale non-primary demoted snapshots on the secondary cluster. These snapshots are not removed immediately because cleanup is only triggered when peer (secondary) is promoted and then demoted. The proposed changes ensures that such snapshots are proactively identified and removed, preventing unbounded buildup. Also added test coverage to verify cleanup behavior. Fixes: https://tracker.ceph.com/issues/76155 Signed-off-by: Miki Patel --- qa/workunits/rbd/rbd_mirror.sh | 29 +++++++ .../snapshot/test_mock_Replayer.cc | 81 +++++++++++++++++++ .../image_replayer/snapshot/Replayer.cc | 7 +- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/qa/workunits/rbd/rbd_mirror.sh b/qa/workunits/rbd/rbd_mirror.sh index 99166f169ba..b19005c9d36 100755 --- a/qa/workunits/rbd/rbd_mirror.sh +++ b/qa/workunits/rbd/rbd_mirror.sh @@ -355,6 +355,35 @@ if [ "${RBD_MIRROR_MODE}" = "snapshot" ]; then test "$(count_mirror_snaps ${CLUSTER2} ${POOL} ${image})" -le 3 fi +if [ "${RBD_MIRROR_MODE}" = "snapshot" ]; then + testlog "TEST: demote and promote on same cluster in loop" + for i in `seq 1 20`; do + last_demote_snap_id='' + demote_image ${CLUSTER2} ${POOL} ${image} + wait_for_image_replay_stopped ${CLUSTER1} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+unknown' + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+unknown' + get_newest_complete_mirror_snapshot_id ${CLUSTER2} ${POOL} ${image} last_demote_snap_id + wait_for_non_primary_snap_present ${CLUSTER1} ${POOL} ${image} ${last_demote_snap_id} + + promote_image ${CLUSTER2} ${POOL} ${image} + wait_for_image_replay_started ${CLUSTER1} ${POOL} ${image} + wait_for_status_in_pool_dir ${CLUSTER2} ${POOL} ${image} 'up+stopped' + wait_for_status_in_pool_dir ${CLUSTER1} ${POOL} ${image} 'up+replaying' + # check non-primary demote snapshot is removed + wait_for_non_primary_snap_not_present ${CLUSTER1} ${POOL} ${image} ${last_demote_snap_id} + done + # expected snapshots on CLUSTER1: non-primary + SNAPS=$(get_snaps_json ${CLUSTER1} ${POOL} ${image}) + jq -e 'length == 1' <<< ${SNAPS} + jq -e '.[0].namespace["type"] == "mirror" and .[0].namespace["state"] == "non-primary"' <<< ${SNAPS} + # expected snapshots on CLUSTER2: primary demoted, primary + SNAPS=$(get_snaps_json ${CLUSTER2} ${POOL} ${image}) + jq -e 'length == 2' <<< ${SNAPS} + jq -e '.[0].namespace["type"] == "mirror" and .[0].namespace["state"] == "demoted" and (.[0]["name"] | startswith(".mirror.primary"))' <<< ${SNAPS} + jq -e '.[1].namespace["type"] == "mirror" and .[1].namespace["state"] == "primary"' <<< ${SNAPS} +fi + testlog "TEST: force promote" force_promote_image=test_force_promote create_image_and_enable_mirror ${CLUSTER2} ${POOL} ${force_promote_image} ${RBD_MIRROR_MODE} diff --git a/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc b/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc index a2dabf2e0ae..07f7a4b168c 100644 --- a/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc +++ b/src/test/rbd_mirror/image_replayer/snapshot/test_mock_Replayer.cc @@ -3511,6 +3511,87 @@ TEST_F(TestMockImageReplayerSnapshotReplayer, ApplyImageStateErrorPendingShutdow ASSERT_EQ(0, shutdown_ctx.wait()); } +TEST_F(TestMockImageReplayerSnapshotReplayer, PruneObsoleteNonPrimaryDemotedSnapshot) { + librbd::MockTestImageCtx mock_local_image_ctx{*m_local_image_ctx}; + librbd::MockTestImageCtx mock_remote_image_ctx{*m_remote_image_ctx}; + + MockThreads mock_threads(m_threads); + expect_work_queue_repeatedly(mock_threads); + + MockReplayerListener mock_replayer_listener; + expect_notification(mock_threads, mock_replayer_listener); + + InSequence seq; + + MockInstanceWatcher mock_instance_watcher; + MockImageMeta mock_image_meta; + MockStateBuilder mock_state_builder(mock_local_image_ctx, + mock_remote_image_ctx, + mock_image_meta); + MockReplayer mock_replayer{&mock_threads, &mock_instance_watcher, + "local mirror uuid", "local mirror peer uuid", + &m_pool_meta_cache, &mock_state_builder, + &mock_replayer_listener}; + m_pool_meta_cache.set_remote_pool_meta( + m_remote_fsid, m_remote_io_ctx.get_id(), + {"remote mirror uuid", "remote mirror peer uuid"}); + + librbd::UpdateWatchCtx* update_watch_ctx = nullptr; + ASSERT_EQ(0, init_entry_replayer(mock_replayer, mock_threads, + mock_local_image_ctx, + mock_remote_image_ctx, + mock_replayer_listener, + mock_image_meta, + &update_watch_ctx)); + + // Remote: PRIMARY_DEMOTED snap1 + PRIMARY snap2 + mock_remote_image_ctx.snap_info = { + {1U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY_DEMOTED, + {}, "", CEPH_NOSNAP, true, 0, {}}, + 0, {}, 0, 0, {}}}, + {2U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_PRIMARY, + {"remote mirror peer uuid"}, "", CEPH_NOSNAP, true, 0, {}}, + 0, {}, 0, 0, {}}}}; + + // Local: NON_PRIMARY_DEMOTED snap1 + NON_PRIMARY snap2 (already synced) + mock_local_image_ctx.snap_info = { + {11U, librbd::SnapInfo{"snap1", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED, + {"local mirror peer uuid"}, "remote mirror uuid", 1, true, 0, {}}, + 0, {}, 0, 0, {}}}, + {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, + {}, "remote mirror uuid", 2, true, 0, {}}, + 0, {}, 0, 0, {}}}}; + + expect_load_image_meta(mock_image_meta, false, 0); + expect_is_refresh_required(mock_remote_image_ctx, false); + expect_is_refresh_required(mock_local_image_ctx, false); + expect_prune_mirror_snapshot(mock_local_image_ctx, 11, 0); + + // idle + expect_load_image_meta(mock_image_meta, false, 0); + expect_is_refresh_required(mock_remote_image_ctx, false); + expect_is_refresh_required(mock_local_image_ctx, true); + expect_refresh( + mock_local_image_ctx, { + {12U, librbd::SnapInfo{"snap2", cls::rbd::MirrorSnapshotNamespace{ + cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY, + {}, "remote mirror uuid", 2, true, 0, {}}, + 0, {}, 0, 0, {}}}, + }, 0); + + // wake-up replayer + update_watch_ctx->handle_notify(); + ASSERT_EQ(0, wait_for_notification(1)); + + ASSERT_EQ(0, shut_down_entry_replayer(mock_replayer, mock_threads, + mock_local_image_ctx, + mock_remote_image_ctx)); +} + } // namespace snapshot } // namespace image_replayer } // namespace mirror diff --git a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc index b85edfc105d..10167185d4a 100644 --- a/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc +++ b/src/tools/rbd_mirror/image_replayer/snapshot/Replayer.cc @@ -517,8 +517,11 @@ void Replayer::scan_local_mirror_snapshots( // if remote has new snapshots, we would sync from here m_local_snap_id_start = local_snap_id; ceph_assert(m_local_snap_id_end == CEPH_NOSNAP); - - if (mirror_ns->mirror_peer_uuids.empty()) { + const auto& peer_uuids = mirror_ns->mirror_peer_uuids; + if (peer_uuids.empty() || + (mirror_ns->state == cls::rbd::MIRROR_SNAPSHOT_STATE_NON_PRIMARY_DEMOTED && + peer_uuids.size() == 1 && + peer_uuids.count(m_local_mirror_peer_uuid) == 1)) { // no other peer will attempt to sync to this snapshot so store as // a candidate for removal prune_snap_ids.insert(local_snap_id); From 222a39366d27aaadf2c96a33b8a29275f233afab Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 24 Jun 2026 01:48:05 +0000 Subject: [PATCH 420/596] rgw/beast: (non-functional) refactor Connection and ConnectionList to header Just like rgw_asio_frontend_timer.h, factor out the Connection struct and ConnectionList class from the anonymous namespace in rgw_asio_frontend.cc to a new header rgw_asio_frontend_connection.h (under namespace rgw::asio). This is especially needed to get better coverage thru unittesting. This is non-functional: no logic changes, the types, methods, and behavior are identical as before. The frontend .cc uses type aliases to maintain the same unqualified names. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_asio_frontend.cc | 61 +++-------------------- src/rgw/rgw_asio_frontend_connection.h | 68 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 55 deletions(-) create mode 100644 src/rgw/rgw_asio_frontend_connection.h diff --git a/src/rgw/rgw_asio_frontend.cc b/src/rgw/rgw_asio_frontend.cc index 694eb88c544..8148001ef3c 100644 --- a/src/rgw/rgw_asio_frontend.cc +++ b/src/rgw/rgw_asio_frontend.cc @@ -44,6 +44,7 @@ #include "rgw_zone.h" +#include "rgw_asio_frontend_connection.h" #include "rgw_asio_frontend_timer.h" #include "rgw_dmclock_async_scheduler.h" @@ -57,13 +58,11 @@ namespace http = boost::beast::http; namespace ssl = boost::asio::ssl; #endif -struct Connection; - using timeout_timer = rgw::basic_timeout_timer; + boost::asio::any_io_executor, rgw::asio::Connection>; -static constexpr size_t parse_buffer_size = 65536; -using parse_buffer = boost::beast::flat_static_buffer; +static constexpr size_t parse_buffer_size = rgw::asio::parse_buffer_size; +using parse_buffer = rgw::asio::parse_buffer; // use mmap/mprotect to allocate 512k coroutine stacks auto make_stack_allocator() { @@ -407,56 +406,8 @@ void handle_connection(boost::asio::io_context& context, } } -// timeout support requires that connections are reference-counted, because the -// timeout_handler can outlive the coroutine -struct Connection : boost::intrusive::list_base_hook<>, - boost::intrusive_ref_counter -{ - tcp::socket socket; - parse_buffer buffer; - - explicit Connection(tcp::socket&& socket) noexcept - : socket(std::move(socket)) {} - - void close(boost::system::error_code& ec) { - socket.close(ec); - } - - tcp::socket& get_socket() { return socket; } -}; - -class ConnectionList { - using List = boost::intrusive::list; - List connections; - std::mutex mutex; - - void remove(Connection& c) { - std::lock_guard lock{mutex}; - if (c.is_linked()) { - connections.erase(List::s_iterator_to(c)); - } - } - public: - class Guard { - ConnectionList *list; - Connection *conn; - public: - Guard(ConnectionList *list, Connection *conn) : list(list), conn(conn) {} - ~Guard() { list->remove(*conn); } - }; - [[nodiscard]] Guard add(Connection& conn) { - std::lock_guard lock{mutex}; - connections.push_back(conn); - return Guard{this, &conn}; - } - void close(boost::system::error_code& ec) { - std::lock_guard lock{mutex}; - for (auto& conn : connections) { - conn.socket.close(ec); - } - connections.clear(); - } -}; +using rgw::asio::Connection; +using rgw::asio::ConnectionList; namespace dmc = rgw::dmclock; class AsioFrontend { diff --git a/src/rgw/rgw_asio_frontend_connection.h b/src/rgw/rgw_asio_frontend_connection.h new file mode 100644 index 00000000000..56d5e233d0b --- /dev/null +++ b/src/rgw/rgw_asio_frontend_connection.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace rgw::asio { + +using tcp = boost::asio::ip::tcp; + +static constexpr size_t parse_buffer_size = 65536; +using parse_buffer = boost::beast::flat_static_buffer; + +// timeout support requires that connections are reference-counted, because the +// timeout_handler can outlive the coroutine +struct Connection : boost::intrusive::list_base_hook<>, + boost::intrusive_ref_counter +{ + tcp::socket socket; + parse_buffer buffer; + + explicit Connection(tcp::socket&& socket) noexcept + : socket(std::move(socket)) {} + + void close(boost::system::error_code& ec) { + socket.close(ec); + } + + tcp::socket& get_socket() { return socket; } +}; + +class ConnectionList { + using List = boost::intrusive::list; + List connections; + std::mutex mutex; + + void remove(Connection& c) { + std::lock_guard lock{mutex}; + if (c.is_linked()) { + connections.erase(List::s_iterator_to(c)); + } + } + public: + class Guard { + ConnectionList *list; + Connection *conn; + public: + Guard(ConnectionList *list, Connection *conn) : list(list), conn(conn) {} + ~Guard() { list->remove(*conn); } + }; + [[nodiscard]] Guard add(Connection& conn) { + std::lock_guard lock{mutex}; + connections.push_back(conn); + return Guard{this, &conn}; + } + void close(boost::system::error_code& ec) { + std::lock_guard lock{mutex}; + for (auto& conn : connections) { + conn.socket.close(ec); + } + connections.clear(); + } +}; + +} // namespace rgw::asio From 15915c85e1a36528c659e32d3f863030212576c1 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 24 Jun 2026 02:35:17 +0000 Subject: [PATCH 421/596] rgw/beast: fix shutdown crash in ConnectionList::close() ConnectionList::close() is called from the main thread during AsioFrontend::stop(), but io_context pool threads may still have pending async_write operations on those sockets. The old code called socket.close() which is a boost::asio thread-safety violation: - it NULLifies descriptor_data in the epoll reactor which causes concurrent start_op calls to dereference NULL (SIGSEGV), or - it deregisters the fd from epoll, orphaning pending operations so their completion handlers would never fire (hang) Fix by using socket.cancel() + native ::shutdown(): - cancel() dequeues pending operations from the reactor's per-descriptor queue and adds completions with operation_aborted. This should handle writes blocked in epoll. - shutdown(fd, SHUT_RDWR) disables the transport so any strand-queued writes that haven't entered epoll yet will fail with EPIPE on the next send() attempt Also remove connections.clear() since coroutines exited via I/O error and their RAII Guard removes them from the list on destruction. This follows the same principle as the fix for https://tracker.ceph.com/issues/58670 which handles the same scenario in timeout_handler path. Bottomlime, socket operations must not be performed from outside the owning strand. Fixes: https://tracker.ceph.com/issues/77658 Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_asio_frontend_connection.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/rgw/rgw_asio_frontend_connection.h b/src/rgw/rgw_asio_frontend_connection.h index 56d5e233d0b..18e46269bb8 100644 --- a/src/rgw/rgw_asio_frontend_connection.h +++ b/src/rgw/rgw_asio_frontend_connection.h @@ -59,9 +59,12 @@ class ConnectionList { void close(boost::system::error_code& ec) { std::lock_guard lock{mutex}; for (auto& conn : connections) { - conn.socket.close(ec); + // cancel pending reactor operations which delivers operation_aborted + // to completion handlers and then shutdown the transport so any + // subsequent I/O attempts fail immediately. + conn.socket.cancel(ec); + conn.socket.shutdown(tcp::socket::shutdown_both, ec); } - connections.clear(); } }; From 179b873e9bf2826a5db9fef50e76c2bef3d47438 Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 24 Jun 2026 14:34:04 +0000 Subject: [PATCH 422/596] rgw/beast: (non-functional change) add size method to ConnectionList This is meant to be used in unittesting. Signed-off-by: Oguzhan Ozmen --- src/rgw/rgw_asio_frontend_connection.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rgw/rgw_asio_frontend_connection.h b/src/rgw/rgw_asio_frontend_connection.h index 18e46269bb8..d06ef98849a 100644 --- a/src/rgw/rgw_asio_frontend_connection.h +++ b/src/rgw/rgw_asio_frontend_connection.h @@ -35,7 +35,7 @@ struct Connection : boost::intrusive::list_base_hook<>, class ConnectionList { using List = boost::intrusive::list; List connections; - std::mutex mutex; + mutable std::mutex mutex; void remove(Connection& c) { std::lock_guard lock{mutex}; @@ -56,6 +56,10 @@ class ConnectionList { connections.push_back(conn); return Guard{this, &conn}; } + size_t size() const { + std::lock_guard lock{mutex}; + return connections.size(); + } void close(boost::system::error_code& ec) { std::lock_guard lock{mutex}; for (auto& conn : connections) { From 19748f1767b85b44c17acca7318957118e196fdf Mon Sep 17 00:00:00 2001 From: Oguzhan Ozmen Date: Wed, 24 Jun 2026 02:36:06 +0000 Subject: [PATCH 423/596] test/rgw/beast: add unit test for ConnectionList shutdown behavior Tests: https://tracker.ceph.com/issues/77658 Signed-off-by: Oguzhan Ozmen --- src/test/rgw/CMakeLists.txt | 4 + .../rgw/test_rgw_asio_frontend_connection.cc | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/test/rgw/test_rgw_asio_frontend_connection.cc diff --git a/src/test/rgw/CMakeLists.txt b/src/test/rgw/CMakeLists.txt index 6d8bdf58067..588504af867 100644 --- a/src/test/rgw/CMakeLists.txt +++ b/src/test/rgw/CMakeLists.txt @@ -481,6 +481,10 @@ add_executable(unittest_rgw_async_utils test_rgw_async_utils.cc) add_ceph_unittest(unittest_rgw_async_utils) target_link_libraries(unittest_rgw_async_utils ${rgw_libs} ${UNITTEST_LIBS}) +add_executable(unittest_rgw_asio_frontend_connection test_rgw_asio_frontend_connection.cc) +add_ceph_unittest(unittest_rgw_asio_frontend_connection) +target_link_libraries(unittest_rgw_asio_frontend_connection ${rgw_libs} ${UNITTEST_LIBS}) + add_executable(unittest_rgw_tag test_rgw_tag.cc) add_ceph_unittest(unittest_rgw_tag) target_link_libraries(unittest_rgw_tag ${rgw_libs} ${UNITTEST_LIBS}) diff --git a/src/test/rgw/test_rgw_asio_frontend_connection.cc b/src/test/rgw/test_rgw_asio_frontend_connection.cc new file mode 100644 index 00000000000..079723f586d --- /dev/null +++ b/src/test/rgw/test_rgw_asio_frontend_connection.cc @@ -0,0 +1,91 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright contributors to the Ceph project + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * + */ + +#include "rgw/rgw_asio_frontend_connection.h" + +#include +#include +#include +#include + +#include + +namespace asio = boost::asio; +using tcp = asio::ip::tcp; +using rgw::asio::Connection; +using rgw::asio::ConnectionList; + +// Testcases for ConnectionList::close() shutdown behavior. +// +// Verifies that close() uses socket.cancel() + ::shutdown() rather than +// socket.close(). Calling socket.close() from outside the socket's strand +// is a boost::asio thread-safety violation that can SIGSEGV or orphaned +// pending operations. + +// Verify that ConnectionList::close() does NOT close the sockets (fd stays +// valid, is_open() remains true) but does shut down the transport (writes +// fail). This ensures we never call socket.close() from outside the strand. +TEST(BeastFrontendShutdown, CloseShutdownsButDoesNotCloseSocket) +{ + asio::io_context ioctx; + ConnectionList connections; + + tcp::acceptor acceptor(ioctx, tcp::endpoint(tcp::v4(), 0)); + acceptor.listen(3); + + tcp::socket c0(ioctx), c1(ioctx), c2(ioctx); + c0.connect(acceptor.local_endpoint()); + auto s0 = acceptor.accept(); + c1.connect(acceptor.local_endpoint()); + auto s1 = acceptor.accept(); + c2.connect(acceptor.local_endpoint()); + auto s2 = acceptor.accept(); + + boost::intrusive_ptr conns[] = { + new Connection(std::move(s0)), + new Connection(std::move(s1)), + new Connection(std::move(s2)), + }; + + { + auto g0 = connections.add(*conns[0]); + auto g1 = connections.add(*conns[1]); + auto g2 = connections.add(*conns[2]); + + ASSERT_EQ(3u, connections.size()); + + boost::system::error_code ec; + connections.close(ec); + + // After close(): sockets must still be "open" from boost::asio's + // perspective (fd not released, descriptor_data not zeroed). + // socket.close() would have set is_open()=false and fd=-1, which is the + // thread-safety violation that causes SIGSEGV. + for (auto& conn : conns) { + EXPECT_TRUE(conn->socket.is_open()); + EXPECT_GE(conn->socket.native_handle(), 0); + } + + // But the transport is shut down — writes must fail + for (auto& conn : conns) { + char buf[] = "test"; + asio::write(conn->socket, asio::buffer(buf), ec); + EXPECT_TRUE(ec.failed()) + << "Write should fail after shutdown"; + } + } // guards destroyed here — connections removed from list + + EXPECT_EQ(0u, connections.size()); +} From cab98604fadb8bdc30d27188c44b313e12568d97 Mon Sep 17 00:00:00 2001 From: Nitzan Mordechai Date: Tue, 23 Jun 2026 10:23:54 +0000 Subject: [PATCH 424/596] mon/config: trim whitespace in config target When set\get\rm config values with leading or trailing spaces, those spaces need to be removed before we are trying to get\set\rm those keys. add trim code before each approch and also for exist keys in config. Fixes: https://tracker.ceph.com/issues/77598 Signed-off-by: Nitzan Mordechai --- qa/workunits/mon/config.sh | 17 +++++++++ src/mon/ConfigMap.cc | 2 ++ src/mon/ConfigMonitor.cc | 2 ++ src/test/mon/test_config_map.cc | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/qa/workunits/mon/config.sh b/qa/workunits/mon/config.sh index 10cbe5630e9..d6c2d850f41 100755 --- a/qa/workunits/mon/config.sh +++ b/qa/workunits/mon/config.sh @@ -73,6 +73,23 @@ ceph config rm client.foo debug_asok ceph config set client.foo debug_asok 66 ceph config rm client.foo 'debug asok' +# whitespace in who/section (e.g. "osd.0 " with stray trailing spaces) +ceph config set 'osd.0 ' debug_asok 44 +ceph config get osd.0 debug_asok | grep 44 +while ! ceph tell osd.0 config get debug_asok | grep 44 +do + sleep 1 +done +ceph config rm osd.0 debug_asok +while ceph tell osd.0 config get debug_asok | grep 44 +do + sleep 1 +done + +ceph config set ' osd.0' debug_asok 55 +ceph config get osd.0 debug_asok | grep 55 +ceph config rm osd.0 debug_asok + # help ceph config help debug_asok | grep debug_asok diff --git a/src/mon/ConfigMap.cc b/src/mon/ConfigMap.cc index bfd837727d1..45111a21562 100644 --- a/src/mon/ConfigMap.cc +++ b/src/mon/ConfigMap.cc @@ -6,6 +6,7 @@ #include "common/entity_name.h" #include +#include #define dout_subsys ceph_subsys_mon #undef dout_prefix @@ -207,6 +208,7 @@ bool ConfigMap::parse_mask( boost::split(split, who, [](char c){ return c == '/'; }); for (unsigned j = 0; j < split.size(); ++j) { auto& i = split[j]; + boost::algorithm::trim(i); if (i == "global") { *section = "global"; continue; diff --git a/src/mon/ConfigMonitor.cc b/src/mon/ConfigMonitor.cc index e377008240e..62f38cd96bd 100644 --- a/src/mon/ConfigMonitor.cc +++ b/src/mon/ConfigMonitor.cc @@ -19,6 +19,7 @@ #include "crush/CrushWrapper.h" #include +#include #define dout_subsys ceph_subsys_mon #undef dout_prefix @@ -296,6 +297,7 @@ bool ConfigMonitor::preprocess_command(MonOpRequestRef op) } else if (prefix == "config get") { string who, name; cmd_getval(cmdmap, "who", who); + boost::algorithm::trim(who); EntityName entity; if (!entity.from_str(who) && diff --git a/src/test/mon/test_config_map.cc b/src/test/mon/test_config_map.cc index eef42de6bd1..f5ab05cc216 100644 --- a/src/test/mon/test_config_map.cc +++ b/src/test/mon/test_config_map.cc @@ -53,6 +53,67 @@ TEST(ConfigMap, parse_key) } } +TEST(ConfigMap, parse_mask) +{ + { + std::string section; + OptionMask mask; + ASSERT_TRUE(ConfigMap::parse_mask("osd.2", §ion, &mask)); + ASSERT_EQ("osd.2", section); + } + { + std::string section; + OptionMask mask; + ASSERT_TRUE(ConfigMap::parse_mask("osd.2 ", §ion, &mask)); + ASSERT_EQ("osd.2", section); + } + { + std::string section; + OptionMask mask; + ASSERT_TRUE(ConfigMap::parse_mask(" osd.2", §ion, &mask)); + ASSERT_EQ("osd.2", section); + } + { + std::string section; + OptionMask mask; + ASSERT_TRUE(ConfigMap::parse_mask(" global ", §ion, &mask)); + ASSERT_EQ("global", section); + } +} + +TEST(ConfigMap, add_option_who_whitespace) +{ + ConfigMap cm; + boost::intrusive_ptr cct{new CephContext(CEPH_ENTITY_TYPE_CLIENT), false}; + auto crush = std::make_unique(); + crush->finalize(); + + int r = cm.add_option( + cct.get(), "debug_ms", "osd.2 ", "1/1", + [&](const std::string& name) { + return nullptr; + }); + ASSERT_EQ(0, r); + ASSERT_EQ(1, cm.by_id.size()); + ASSERT_EQ(1, cm.by_id["osd.2"].options.size()); + + EntityName n; + n.set(CEPH_ENTITY_TYPE_OSD, "2"); + auto c = cm.generate_entity_map(n, {}, crush.get(), "none", nullptr); + ASSERT_EQ(1, c.size()); + ASSERT_EQ("1/1", c["debug_ms"]); + + r = cm.add_option( + cct.get(), "foo", "osd.2", "clean", + [&](const std::string& name) { + return nullptr; + }); + ASSERT_EQ(0, r); + + ASSERT_EQ(1, cm.by_id.size()); + ASSERT_EQ(2, cm.by_id["osd.2"].options.size()); +} + TEST(ConfigMap, add_option) { ConfigMap cm; From 1a0ee9b11dd6f1a54eeff9240519613ce368d596 Mon Sep 17 00:00:00 2001 From: Jaya Prakash Date: Tue, 16 Jun 2026 16:24:41 +0000 Subject: [PATCH 425/596] os/bluestore: add per-instance new allocator perf counters Fixes: https://tracker.ceph.com/issues/76936 Signed-off-by: Jaya Prakash --- src/os/bluestore/AllocatorBase.cc | 28 ++++++++++++++++++++++++++++ src/os/bluestore/AllocatorBase.h | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/os/bluestore/AllocatorBase.cc b/src/os/bluestore/AllocatorBase.cc index 62d78974e27..a7c068dcfeb 100644 --- a/src/os/bluestore/AllocatorBase.cc +++ b/src/os/bluestore/AllocatorBase.cc @@ -207,3 +207,31 @@ void AllocatorBase::FreeStateHistogram::foreach( ++i; } } +void AllocatorPerf::_init_logger(std::string_view name) { + PerfCountersBuilder b(cct, + "bluestore-alloc-" + std::string(name), + l_bluestore_allocator_first, + l_bluestore_allocator_last); + + b.add_time_avg(l_bluestore_allocator_alloc_process_lat, + "alloc_process_lat", + "Allocator processing latency", + "apl", + PerfCountersBuilder::PRIO_USEFUL); + b.add_time_avg(l_bluestore_allocator_lock_wait_lat, + "lock_wait_lat", + "Allocator lock wait latency", + "lwl", + PerfCountersBuilder::PRIO_USEFUL); + + logger = b.create_perf_counters(); + + cct->get_perfcounters_collection()->add(logger); +} +void AllocatorPerf::_shutdown_logger() { + if (logger) { + cct->get_perfcounters_collection()->remove(logger); + delete logger; + logger = nullptr; + } +} diff --git a/src/os/bluestore/AllocatorBase.h b/src/os/bluestore/AllocatorBase.h index eedf766dc80..dc567c3c2c9 100644 --- a/src/os/bluestore/AllocatorBase.h +++ b/src/os/bluestore/AllocatorBase.h @@ -19,6 +19,15 @@ #include "bluestore_types.h" #include "common/ceph_mutex.h" #include "Allocator.h" +#include "common/perf_counters.h" +#include "common/perf_counters_collection.h" + +enum { + l_bluestore_allocator_first = 732300, + l_bluestore_allocator_alloc_process_lat, + l_bluestore_allocator_lock_wait_lat, + l_bluestore_allocator_last +}; class AllocatorBase : public Allocator { protected: @@ -295,4 +304,24 @@ private: SocketHook* asok_hook = nullptr; }; +class AllocatorPerf { +private: + CephContext* cct = nullptr; +protected: + PerfCounters *logger = nullptr; +public: + AllocatorPerf() = delete; + AllocatorPerf(CephContext* cct, std::string_view name) + : cct(cct) + { + _init_logger(name); + } + ~AllocatorPerf() { + _shutdown_logger(); + } +private: + void _init_logger(std::string_view name); + void _shutdown_logger(); +}; + #endif From 3c1dacfb5acddfe5c582dec9803d8138997f58ee Mon Sep 17 00:00:00 2001 From: Jaya Prakash Date: Tue, 16 Jun 2026 16:26:33 +0000 Subject: [PATCH 426/596] os/bluestore: measure allocator lock wait and processing latency Fixes: https://tracker.ceph.com/issues/76936 Signed-off-by: Jaya Prakash --- src/os/bluestore/AvlAllocator.cc | 17 ++++++++++++++++- src/os/bluestore/AvlAllocator.h | 2 +- src/os/bluestore/Btree2Allocator.cc | 17 ++++++++++++++++- src/os/bluestore/Btree2Allocator.h | 2 +- src/os/bluestore/BtreeAllocator.cc | 17 ++++++++++++++++- src/os/bluestore/BtreeAllocator.h | 2 +- src/os/bluestore/HybridAllocator_impl.h | 10 ++++++++++ src/os/bluestore/StupidAllocator.cc | 13 +++++++++++++ src/os/bluestore/StupidAllocator.h | 2 +- 9 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/os/bluestore/AvlAllocator.cc b/src/os/bluestore/AvlAllocator.cc index b6a0325b5eb..13d08a08821 100644 --- a/src/os/bluestore/AvlAllocator.cc +++ b/src/os/bluestore/AvlAllocator.cc @@ -368,6 +368,7 @@ AvlAllocator::AvlAllocator(CephContext* cct, uint64_t max_mem, std::string_view name) : AllocatorBase(name, device_size, block_size), + AllocatorPerf(cct, name), cct(cct), range_size_alloc_threshold( cct->_conf.get_val("bluestore_avl_alloc_bf_threshold")), @@ -421,8 +422,22 @@ int64_t AvlAllocator::allocate( max_alloc_size >= cap) { max_alloc_size = p2align(uint64_t(cap), (uint64_t)block_size); } + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(lock); - return _allocate(want, unit, max_alloc_size, hint, extents); + + auto lock_acquired = mono_clock::now(); + + auto ret = _allocate(want, unit, max_alloc_size, hint, extents); + + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); + + return ret; } void AvlAllocator::release(const release_set_t& release_set) { diff --git a/src/os/bluestore/AvlAllocator.h b/src/os/bluestore/AvlAllocator.h index b0a9fdd0cfb..c2c86acb204 100644 --- a/src/os/bluestore/AvlAllocator.h +++ b/src/os/bluestore/AvlAllocator.h @@ -50,7 +50,7 @@ struct range_seg_t { boost::intrusive::avl_set_member_hook<> size_hook; }; -class AvlAllocator : public AllocatorBase { +class AvlAllocator : public AllocatorBase, public AllocatorPerf { struct dispose_rs { void operator()(range_seg_t* p) { diff --git a/src/os/bluestore/Btree2Allocator.cc b/src/os/bluestore/Btree2Allocator.cc index 2775c7347f6..7531f059852 100644 --- a/src/os/bluestore/Btree2Allocator.cc +++ b/src/os/bluestore/Btree2Allocator.cc @@ -24,6 +24,7 @@ Btree2Allocator::Btree2Allocator(CephContext* _cct, bool with_cache, std::string_view name) : AllocatorBase(name, device_size, block_size), + AllocatorPerf(_cct, name), myTraits(RANGE_SIZE_BUCKET_COUNT), cct(_cct), range_count_cap(max_mem / sizeof(range_seg_t)) @@ -90,8 +91,22 @@ int64_t Btree2Allocator::allocate( extents->emplace_back(cached_chunk_offs, want); return want; } + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(lock); - return _allocate(want, unit, max_alloc_size, hint, extents); + + auto lock_acquired = mono_clock::now(); + + auto ret = _allocate(want, unit, max_alloc_size, hint, extents); + + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); + + return ret; } void Btree2Allocator::release(const release_set_t& release_set) diff --git a/src/os/bluestore/Btree2Allocator.h b/src/os/bluestore/Btree2Allocator.h index 5aa5b5bee5c..6e6a54405b5 100644 --- a/src/os/bluestore/Btree2Allocator.h +++ b/src/os/bluestore/Btree2Allocator.h @@ -20,7 +20,7 @@ * * */ -class Btree2Allocator : public AllocatorBase { +class Btree2Allocator : public AllocatorBase, public AllocatorPerf { enum { RANGE_SIZE_BUCKET_COUNT = 14, }; diff --git a/src/os/bluestore/BtreeAllocator.cc b/src/os/bluestore/BtreeAllocator.cc index 5a7ecc4ad10..629c7a07349 100644 --- a/src/os/bluestore/BtreeAllocator.cc +++ b/src/os/bluestore/BtreeAllocator.cc @@ -354,6 +354,7 @@ BtreeAllocator::BtreeAllocator(CephContext* cct, uint64_t max_mem, std::string_view name) : AllocatorBase(name, device_size, block_size), + AllocatorPerf(cct, name), range_size_alloc_threshold( cct->_conf.get_val("bluestore_avl_alloc_bf_threshold")), range_size_alloc_free_pct( @@ -397,8 +398,22 @@ int64_t BtreeAllocator::allocate( max_alloc_size >= cap) { max_alloc_size = p2align(uint64_t(cap), (uint64_t)block_size); } + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(lock); - return _allocate(want, unit, max_alloc_size, hint, extents); + + auto lock_acquired = mono_clock::now(); + + auto ret = _allocate(want, unit, max_alloc_size, hint, extents); + + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); + + return ret; } void BtreeAllocator::release(const interval_set& release_set) { diff --git a/src/os/bluestore/BtreeAllocator.h b/src/os/bluestore/BtreeAllocator.h index d4328169bdd..897c06ec372 100644 --- a/src/os/bluestore/BtreeAllocator.h +++ b/src/os/bluestore/BtreeAllocator.h @@ -11,7 +11,7 @@ #include "os/bluestore/bluestore_types.h" #include "include/mempool.h" -class BtreeAllocator : public AllocatorBase { +class BtreeAllocator : public AllocatorBase, public AllocatorPerf { struct range_seg_t { uint64_t start; ///< starting offset of this segment uint64_t end; ///< ending offset (non-inclusive) diff --git a/src/os/bluestore/HybridAllocator_impl.h b/src/os/bluestore/HybridAllocator_impl.h index 590ee49bb3e..6d1053abae4 100644 --- a/src/os/bluestore/HybridAllocator_impl.h +++ b/src/os/bluestore/HybridAllocator_impl.h @@ -35,8 +35,12 @@ int64_t HybridAllocatorBase::allocate( max_alloc_size = p2align(uint64_t(cap), (uint64_t)T::get_block_size()); } + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(T::get_lock()); + auto lock_acquired = mono_clock::now(); + // try bitmap first to avoid unneeded contiguous extents split if // desired amount is less than shortes range in AVL or Btree2 bool primary_first = !(bmap_alloc && @@ -63,6 +67,12 @@ int64_t HybridAllocatorBase::allocate( ceph_assert(orig_size == extents->size()); } } + this->logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + this->logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); return res ? res : -ENOSPC; } diff --git a/src/os/bluestore/StupidAllocator.cc b/src/os/bluestore/StupidAllocator.cc index 1533cf29870..4b908cb7f5e 100644 --- a/src/os/bluestore/StupidAllocator.cc +++ b/src/os/bluestore/StupidAllocator.cc @@ -15,6 +15,7 @@ StupidAllocator::StupidAllocator(CephContext* cct, int64_t _block_size, std::string_view name) : AllocatorBase(name, capacity, _block_size), + AllocatorPerf(cct, name), cct(cct), num_free(0), free(10) { @@ -56,7 +57,12 @@ int64_t StupidAllocator::allocate_int( uint64_t want_size, uint64_t alloc_unit, int64_t hint, uint64_t *offset, uint32_t *length) { + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(lock); + + auto lock_acquired = mono_clock::now(); + ldout(cct, 10) << __func__ << " want_size 0x" << std::hex << want_size << " alloc_unit 0x" << alloc_unit << " hint 0x" << hint << std::dec @@ -164,6 +170,13 @@ int64_t StupidAllocator::allocate_int( num_free -= *length; ceph_assert(num_free >= 0); last_alloc = *offset + *length; + + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); return 0; } diff --git a/src/os/bluestore/StupidAllocator.h b/src/os/bluestore/StupidAllocator.h index fe75e90bd9d..3f0af4efdb5 100644 --- a/src/os/bluestore/StupidAllocator.h +++ b/src/os/bluestore/StupidAllocator.h @@ -14,7 +14,7 @@ #include "include/mempool.h" #include "common/ceph_mutex.h" -class StupidAllocator : public AllocatorBase { +class StupidAllocator : public AllocatorBase, public AllocatorPerf { CephContext* cct; ceph::mutex lock = ceph::make_mutex("StupidAllocator::lock"); From 1004a24ecaf344545522c577206215a7b8857716 Mon Sep 17 00:00:00 2001 From: Jaya Prakash Date: Wed, 17 Jun 2026 12:47:38 +0000 Subject: [PATCH 427/596] os/bluestore: measure Bitmap allocator lock wait and processing latency Fixes: https://tracker.ceph.com/issues/76936 Signed-off-by: Jaya Prakash --- src/os/bluestore/BitmapAllocator.cc | 1 + src/os/bluestore/fastbmap_allocator_impl.h | 25 ++++++++++++++++++- .../objectstore/fastbmap_allocator_test.cc | 2 ++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/os/bluestore/BitmapAllocator.cc b/src/os/bluestore/BitmapAllocator.cc index 3ab43bea344..ec53216cf98 100644 --- a/src/os/bluestore/BitmapAllocator.cc +++ b/src/os/bluestore/BitmapAllocator.cc @@ -14,6 +14,7 @@ BitmapAllocator::BitmapAllocator(CephContext* _cct, int64_t alloc_unit, std::string_view name) : AllocatorBase(name, capacity, alloc_unit), + AllocatorLevel02(_cct, name), cct(_cct) { ldout(cct, 10) << __func__ << " 0x" << std::hex << capacity << "/" diff --git a/src/os/bluestore/fastbmap_allocator_impl.h b/src/os/bluestore/fastbmap_allocator_impl.h index fbf3fda0b32..2ae809ec431 100644 --- a/src/os/bluestore/fastbmap_allocator_impl.h +++ b/src/os/bluestore/fastbmap_allocator_impl.h @@ -15,6 +15,7 @@ #include #include #include +#include typedef uint64_t slot_t; @@ -36,6 +37,7 @@ typedef std::vector slot_vector_t; #include "include/ceph_assert.h" #include "common/likely.h" #include "os/bluestore/bluestore_types.h" +#include "AllocatorBase.h" #include "include/mempool.h" #include "common/ceph_mutex.h" @@ -524,9 +526,13 @@ public: }; template -class AllocatorLevel02 : public AllocatorLevel +class AllocatorLevel02 : public AllocatorLevel, public AllocatorPerf { public: + AllocatorLevel02(CephContext* cct, std::string_view name) + : AllocatorPerf(cct, name) + {} + uint64_t debug_get_free(uint64_t pos0 = 0, uint64_t pos1 = 0) { std::lock_guard l(lock); @@ -720,9 +726,19 @@ protected: uint64_t l1_w = slots_per_slotset * l1._children_per_slot(); + auto lock_wait_start = mono_clock::now(); + std::lock_guard l(lock); + auto lock_acquired = mono_clock::now(); + if (available < min_length) { + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); return; } if (hint != -1) { @@ -783,6 +799,13 @@ protected: auto allocated_here = *allocated - prev_allocated; ceph_assert(available >= allocated_here); available -= allocated_here; + + logger->tinc_with_max( + l_bluestore_allocator_alloc_process_lat, + mono_clock::now() - lock_acquired); + logger->tinc_with_max( + l_bluestore_allocator_lock_wait_lat, + lock_acquired - lock_wait_start); } #ifndef NON_CEPH_BUILD diff --git a/src/test/objectstore/fastbmap_allocator_test.cc b/src/test/objectstore/fastbmap_allocator_test.cc index 83babab567f..7cd8d6adb81 100644 --- a/src/test/objectstore/fastbmap_allocator_test.cc +++ b/src/test/objectstore/fastbmap_allocator_test.cc @@ -27,6 +27,8 @@ public: class TestAllocatorLevel02 : public AllocatorLevel02 { public: + TestAllocatorLevel02() + : AllocatorLevel02(g_ceph_context, "bitmap-test") {} void init(uint64_t capacity, uint64_t alloc_unit) { _init(capacity, alloc_unit); From 948de224df12b8b383460b86ba520b191dc275bc Mon Sep 17 00:00:00 2001 From: Nitzan Mordechai Date: Mon, 8 Jun 2026 05:18:55 +0000 Subject: [PATCH 428/596] mgr: fix dispatch throttle bottleneck causing OSD connection timeouts The mgr dispatch throttle (ms_dispatch_throttle_bytes, default 100MB) was far smaller than the OSD throttle (mgr_osd_bytes, 512MB), making the dispatch queue the effective bottleneck on large clusters. When the dispatch queue fills, OSD connections stall waiting for throttle space. These connections time out and reconnect, causing connection churn and heap growth from repeated connection setup/teardown. The MGR also falls behind on PG stats processing, leading to unknown and inactive PGs. Add mgr_dispatch_throttle_bytes (default 1GB) and a Messenger::set_dispatch_throttle_size() API to configure it at init time. The new default is sized to accommodate the sum of all per-type policy throttles, ensuring the dispatch queue is never the bottleneck. Fixes: https://tracker.ceph.com/issues/66310 Signed-off-by: Nitzan Mordechai --- doc/mgr/administrator.rst | 4 ++-- src/common/options/mgr.yaml.in | 18 +++++++++++++++++- src/mgr/DaemonServer.cc | 2 ++ src/msg/Messenger.h | 7 +++++++ src/msg/async/AsyncMessenger.h | 4 ++++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/doc/mgr/administrator.rst b/doc/mgr/administrator.rst index 6d60e897b58..5b24ba60c70 100644 --- a/doc/mgr/administrator.rst +++ b/doc/mgr/administrator.rst @@ -161,9 +161,9 @@ The Manager automatically adjusts :confval:`mgr_stats_period` based on message q depth to prevent overload during high cluster activity. This feature is enabled by default and can be controlled with the following settings: -- :confval:`mgr_stats_period_autotune` (boolean, default: true): Enable or disable +- :confval:`mgr_stats_period_autotune` : Enable or disable automatic tuning of the stats period. -- :confval:`mgr_stats_period_autotune_queue_threshold` (integer, default: 100): +- :confval:`mgr_stats_period_autotune_queue_threshold` : The message queue depth threshold that triggers an increase in the stats period. When the queue depth exceeds this threshold, the stats period is increased to diff --git a/src/common/options/mgr.yaml.in b/src/common/options/mgr.yaml.in index b9fa2a9b0c3..4c3e65229df 100644 --- a/src/common/options/mgr.yaml.in +++ b/src/common/options/mgr.yaml.in @@ -56,12 +56,28 @@ options: long_desc: When mgr_stats_period_autotune is enabled, the Manager will increase the stats reporting period if the incoming message queue exceeds this threshold. Higher values make the system less sensitive to temporary queue spikes but may - allow longer periods of Manager overload. + allow longer periods of Manager overload. Should be well below the smallest + of mgr_mon_messages, mgr_mds_messages, and mgr_osd_messages so the autotune + fires before any per-connection throttle saturates. default: 100 services: - mgr see_also: - mgr_stats_period +- name: mgr_dispatch_throttle_bytes + type: size + level: advanced + desc: Limit total size of messages waiting in the mgr dispatch queue + long_desc: Sets the maximum total bytes of messages queued in the mgr dispatch + throttle. Overrides ms_dispatch_throttle_bytes for the mgr process. Should be + set to at least the sum of all per-type byte throttles (mgr_osd_bytes + + mgr_mds_bytes + mgr_client_bytes) to avoid the dispatch queue becoming a + bottleneck before per-connection throttles engage. + default: 1_G + services: + - mgr + see_also: + - ms_dispatch_throttle_bytes - name: mgr_client_bytes type: size level: dev diff --git a/src/mgr/DaemonServer.cc b/src/mgr/DaemonServer.cc index a7ddac0868d..822b508cbd9 100644 --- a/src/mgr/DaemonServer.cc +++ b/src/mgr/DaemonServer.cc @@ -176,6 +176,8 @@ int DaemonServer::init(uint64_t gid, entity_addrvec_t client_addrs) entity_name_t::MGR(gid), "mgr", Messenger::get_random_nonce()); + msgr->set_dispatch_throttle_size( + g_conf().get_val("mgr_dispatch_throttle_bytes")); msgr->set_default_policy(Messenger::Policy::stateless_server(0)); // throttle policy msgr->set_policy(entity_name_t::TYPE_OSD, diff --git a/src/msg/Messenger.h b/src/msg/Messenger.h index 8dc49e2483c..88c84ea9069 100644 --- a/src/msg/Messenger.h +++ b/src/msg/Messenger.h @@ -373,6 +373,13 @@ public: * you must not destroy them before you destroy the Messenger. */ virtual void set_policy_throttlers(int type, Throttle *bytes, Throttle *msgs=NULL) = 0; + /** + * set the maximum size of the dispatch queue throttle + * + * This is an init-time function and must be called *before* calling + * start(). + */ + virtual void set_dispatch_throttle_size(uint64_t size) {} /** * set the default send priority * diff --git a/src/msg/async/AsyncMessenger.h b/src/msg/async/AsyncMessenger.h index 3edbe00dcad..53ba370c4ef 100644 --- a/src/msg/async/AsyncMessenger.h +++ b/src/msg/async/AsyncMessenger.h @@ -125,6 +125,10 @@ public: double get_dispatch_queue_max_age(utime_t now) const override { return dispatch_queue.get_max_age(now); } + + void set_dispatch_throttle_size(uint64_t size) override { + dispatch_queue.dispatch_throttler.reset_max(size); + } /** @} Accessors */ /** From 1874a4410c01abc43a544fc658dd147a7dd31def Mon Sep 17 00:00:00 2001 From: Gil Bregman Date: Thu, 25 Jun 2026 17:33:21 +0300 Subject: [PATCH 429/596] nvmeof: Change the NVMEOF image version to 1.9 Fixes: https://tracker.ceph.com/issues/77709 Signed-off-by: Gil Bregman --- src/python-common/ceph/cephadm/images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python-common/ceph/cephadm/images.py b/src/python-common/ceph/cephadm/images.py index 1c1e5b54a02..86c3f3d6365 100644 --- a/src/python-common/ceph/cephadm/images.py +++ b/src/python-common/ceph/cephadm/images.py @@ -39,7 +39,7 @@ class DefaultImages(Enum): GRAFANA = _create_image('quay.io/ceph/grafana:12.3.1', 'grafana') HAPROXY = _create_image('quay.io/ceph/haproxy:2.3', 'haproxy') KEEPALIVED = _create_image('quay.io/ceph/keepalived:2.2.4', 'keepalived') - NVMEOF = _create_image('quay.io/ceph/nvmeof:1.8', 'nvmeof') + NVMEOF = _create_image('quay.io/ceph/nvmeof:1.9', 'nvmeof') SNMP_GATEWAY = _create_image( 'docker.io/maxwo/snmp-notifier:v1.2.1', 'snmp_gateway' ) From 6da208dec17c6f325f5d2711d7ca2dd3a34f7d86 Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Mon, 20 Apr 2026 14:33:15 +0530 Subject: [PATCH 430/596] mgr/smb: Add RGWStorage resource class and update Share to support RGW - Add RGWStorage component class for RGW bucket storage backend - Include fields: bucket, user_id, access_key_id, secret_access_key - Add password conversion support for credentials - Update Share class to accept optional rgw field and added convert defination - Update Share validation to support both cephfs and rgw backends - Ensure exactly one storage backend (cephfs or rgw) is specified This is the first step in implementing RGW storage support for SMB shares, similar to the existing NFS RGW export functionality. Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/resources.py | 69 +++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/pybind/mgr/smb/resources.py b/src/pybind/mgr/smb/resources.py index 2956703e68e..118a40fa813 100644 --- a/src/pybind/mgr/smb/resources.py +++ b/src/pybind/mgr/smb/resources.py @@ -400,6 +400,44 @@ class LoginAccessEntry(_RBase): validation.check_access_name(self.name) +@resourcelib.component() +class RGWStorage(_RBase): + """Description of where in an RGW bucket a share is located.""" + + bucket: str + user_id: Optional[str] = None + access_key_id: Optional[str] = None + secret_access_key: Optional[str] = None + + def validate(self) -> None: + if not self.bucket: + raise ValueError('bucket requires a value') + + def convert(self, operation: ConversionOp) -> Self: + """Convert password fields based on the operation.""" + return self.__class__( + bucket=self.bucket, + user_id=self.user_id, + access_key_id=( + _password_convert(self.access_key_id, operation) + if self.access_key_id + else None + ), + secret_access_key=( + _password_convert(self.secret_access_key, operation) + if self.secret_access_key + else None + ), + ) + + @resourcelib.customize + def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource: + rc.user_id.quiet = True + rc.access_key_id.quiet = True + rc.secret_access_key.quiet = True + return rc + + @resourcelib.component() class HostAccessEntry(_RBase): access: HostAccess @@ -461,6 +499,7 @@ class Share(_RBase): comment: Optional[str] = None max_connections: Optional[int] = None cephfs: Optional[CephFSStorage] = None + rgw: Optional[RGWStorage] = None custom_smb_share_options: Optional[Dict[str, str]] = None login_control: Optional[List[LoginAccessEntry]] = None restrict_access: bool = False @@ -481,9 +520,14 @@ class Share(_RBase): validation.check_share_name(self.name) if self.intent != Intent.PRESENT: raise ValueError('Share must have present intent') - # currently only cephfs is supported - if self.cephfs is None: - raise ValueError('a cephfs configuration is required') + # Ensure exactly one storage backend is specified + storage_count = len([b for b in (self.cephfs, self.rgw) if b]) + if storage_count == 0: + raise ValueError( + 'a storage configuration is required (cephfs or rgw)' + ) + if storage_count > 1: + raise ValueError('only one storage backend can be specified') if self.max_connections is not None and self.max_connections < 0: raise ValueError( 'max_connections must be 0 or a non-negative integer' @@ -501,6 +545,25 @@ class Share(_RBase): """Return the .cephfs storage object or raise ValueError if None.""" return checked(self.cephfs) + def convert(self, operation: ConversionOp) -> Self: + """Convert password fields in nested storage components.""" + return self.__class__( + cluster_id=self.cluster_id, + share_id=self.share_id, + intent=self.intent, + name=self.name, + readonly=self.readonly, + browseable=self.browseable, + comment=self.comment, + max_connections=self.max_connections, + cephfs=self.cephfs.convert(operation) if self.cephfs else None, + rgw=self.rgw.convert(operation) if self.rgw else None, + custom_smb_share_options=self.custom_smb_share_options, + login_control=self.login_control, + restrict_access=self.restrict_access, + hosts_access=self.hosts_access, + ) + @resourcelib.customize def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource: rc.restrict_access.quiet = True From 9b4fbb3f2264ba94aac0d7c56bdefa54a36a9e26 Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Mon, 20 Apr 2026 14:37:54 +0530 Subject: [PATCH 431/596] mgr/smb: Add RGW credential management utilities - Add rgw.py module for RGW integration - Implement fetch_rgw_credentials() to get user credentials from radosgw-admin - Implement validate_rgw_bucket() to check bucket existence - Handle bucket owner lookup when user_id not provided - Add proper error handling and logging This provides the foundation for RGW authentication in SMB shares, similar to the NFS RGW export implementation. Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/rgw.py | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/pybind/mgr/smb/rgw.py diff --git a/src/pybind/mgr/smb/rgw.py b/src/pybind/mgr/smb/rgw.py new file mode 100644 index 00000000000..a722a8afe1d --- /dev/null +++ b/src/pybind/mgr/smb/rgw.py @@ -0,0 +1,153 @@ +"""Utilities for RGW integration with SMB.""" + +from typing import Protocol, Tuple, runtime_checkable + +import json +import logging + +log = logging.getLogger(__name__) + + +@runtime_checkable +class ToolExecer(Protocol): + """Protocol for executing tools (e.g., radosgw-admin).""" + + def tool_exec(self, cmd: list[str]) -> Tuple[int, str, str]: + """Execute a tool and return (return_code, stdout, stderr).""" + ... + + +def _fetch_bucket_stats(executor: ToolExecer, bucket: str) -> dict: + """ + Fetch RGW bucket statistics. + Args: + executor: Any object with tool_exec() method + bucket: The RGW bucket name + Returns: + Parsed JSON bucket stats dictionary + Raises: + ValueError: If bucket stats cannot be fetched or parsed + """ + log.debug(f"Fetching stats for bucket {bucket}") + ret, out, err = executor.tool_exec( + ['radosgw-admin', 'bucket', 'stats', '--bucket', bucket] + ) + if ret: + raise ValueError(f'Failed to fetch stats for bucket {bucket}: {err}') + + try: + return json.loads(out) + except json.JSONDecodeError as e: + raise ValueError(f'Failed to parse bucket stats for {bucket}: {e}') + + +def _get_rgw_owner(executor: ToolExecer, bucket: str) -> str: + """ + Fetch RGW user bucket owner. + Args: + executor: Any object with tool_exec() method + bucket: The RGW bucket name + Returns: + user_id: RGW user ID that owns bucket. + Raises: + ValueError: If bucket owner cannot be determined + """ + + try: + stats = _fetch_bucket_stats(executor, bucket) + user_id = stats.get('owner', '') + if not user_id: + raise ValueError(f'No owner found for bucket {bucket}') + + log.debug(f"Bucket {bucket} is owned by user {user_id}") + return user_id + + except ValueError: + raise + + +def _get_rgw_creds(executor: ToolExecer, user_id: str) -> Tuple[str, str]: + """ + Fetch RGW user credentials. + Args: + executor: Any object with tool_exec() method + user_id: The RGW user ID + Returns: + Tuple of (access_key_id, secret_access_key) + Raises: + ValueError: If credentials cannot be fetched + """ + log.debug(f"Fetching credentials for user {user_id}") + ret, out, err = executor.tool_exec( + ['radosgw-admin', 'user', 'info', '--uid', user_id] + ) + if ret: + raise ValueError( + f'Failed to fetch credentials for user {user_id}: {err}' + ) + + try: + j = json.loads(out) + keys = j.get('keys', []) + if not keys: + raise ValueError(f'No keys found for user {user_id}') + + access_key = keys[0].get('access_key', '') + secret_key = keys[0].get('secret_key', '') + + if not access_key or not secret_key: + raise ValueError( + f'Invalid credentials for user {user_id}: ' + f'access_key={bool(access_key)}, secret_key={bool(secret_key)}' + ) + + log.debug(f"Successfully fetched credentials for user {user_id}") + return access_key, secret_key + + except (json.JSONDecodeError, KeyError, IndexError) as e: + raise ValueError(f'Failed to parse user info for {user_id}: {e}') + + +def fetch_rgw_credentials( + executor: ToolExecer, + bucket: str, + user_id: str = '', +) -> Tuple[str, str, str]: + """ + Fetch RGW user credentials for a bucket. + + Args: + executor: Any object with tool_exec() method + bucket: The RGW bucket name + user_id: Optional RGW user ID. If not provided, fetches bucket owner. + + Returns: + Tuple of (user_id, access_key_id, secret_access_key) + + Raises: + ValueError: If bucket owner cannot be determined or credentials not found + """ + if not user_id: + user_id = _get_rgw_owner(executor, bucket) + + access_key, secret_key = _get_rgw_creds(executor, user_id) + return user_id, access_key, secret_key + + +def validate_rgw_bucket(executor: ToolExecer, bucket: str) -> bool: + """ + Validate that an RGW bucket exists. + Args: + executor: Any object with tool_exec() method + bucket: The RGW bucket name + Returns: + True if bucket exists, False otherwise + """ + log.debug(f"Validating bucket {bucket}") + try: + stats = _fetch_bucket_stats(executor, bucket) + # If we can parse the output and it has an owner, bucket exists + return bool(stats.get('owner')) + except ValueError as e: + log.debug(f"Bucket {bucket} validation failed: {e}") + return False From 6a3f3103a8a7498a41c8a90e27952d7e483da11b Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Mon, 20 Apr 2026 14:44:01 +0530 Subject: [PATCH 432/596] mgr/smb: Add CLI command for RGW share creation - Add 'smb share create rgw' CLI command - Import rgw module for credential management - Fetch RGW credentials using radosgw-admin - Create Share resource with RGWStorage backend - Handle errors from credential fetching This enables users to create SMB shares backed by RGW buckets, similar to 'nfs export create rgw' functionality. Example usage: ceph smb share create rgw mysmb share1 mybucket Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/module.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/pybind/mgr/smb/module.py b/src/pybind/mgr/smb/module.py index 9badbe57460..185d1b1718d 100644 --- a/src/pybind/mgr/smb/module.py +++ b/src/pybind/mgr/smb/module.py @@ -25,6 +25,7 @@ from . import ( rados_store, resources, results, + rgw, sqlite_store, utils, ) @@ -101,6 +102,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): authorizer=authorizer, orch=self._orch_backend(enable_orch=uo), earmark_resolver=earmark_resolver, + tool_execer=self, ) def _backend_store(self, store_conf: str = '') -> ConfigStore: @@ -615,6 +617,48 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): if cid == cluster_id ] + @SMBCLICommand('share create rgw', perm='rw') + def share_create_rgw( + self, + cluster_id: str, + share_id: str, + bucket: str, + share_name: str = '', + user_id: str = '', + readonly: bool = False, + ) -> results.Result: + """Create an SMB share backed by RGW""" + try: + # Pass 'self' which conforms to ToolExecer protocol + ( + fetched_user_id, + access_key, + secret_key, + ) = rgw.fetch_rgw_credentials(self, bucket, user_id) + + share = resources.Share( + cluster_id=cluster_id, + share_id=share_id, + name=share_name or share_id, + readonly=readonly, + rgw=resources.RGWStorage( + bucket=bucket, + user_id=fetched_user_id, + access_key_id=access_key, + secret_access_key=secret_key, + ), + ) + return self._apply_res([share], create_only=True).one() + except ValueError as e: + # Create a minimal share resource for error reporting + error_share = resources.Share( + cluster_id=cluster_id, + share_id=share_id, + name=share_name or share_id, + rgw=resources.RGWStorage(bucket='error'), + ) + return results.ErrorResult(error_share, msg=str(e)) + @SMBCLICommand('share create', perm='rw') def share_create( self, From d317e318592922f803de41b16929f6c7483ebcce Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Mon, 20 Apr 2026 14:47:35 +0530 Subject: [PATCH 433/596] mgr/smb: Update handler to process RGW shares, added validation and error handling - Add _generate_rgw_share() function for RGW share configuration - Skip CephFS authorization for RGW shares in cluster assembly - Configure Samba VFS with RGW-specific parameters: - rgw_bucket, rgw_user_id, rgw_access_key, rgw_secret_key - Validate bucket accessibility using radosgw-admin - Ensure proper error handling in share creation flow This enables the handler to generate proper Samba configuration for RGW-backed shares along with validation and error handling. Signed-off-by: Shweta Sodani --- src/pybind/mgr/smb/external.py | 5 + src/pybind/mgr/smb/handler.py | 215 ++++++++++++++++++++++++++++++--- src/pybind/mgr/smb/staging.py | 62 +++++++++- 3 files changed, 262 insertions(+), 20 deletions(-) diff --git a/src/pybind/mgr/smb/external.py b/src/pybind/mgr/smb/external.py index 412efbb4c0c..8937411e28d 100644 --- a/src/pybind/mgr/smb/external.py +++ b/src/pybind/mgr/smb/external.py @@ -49,6 +49,11 @@ def spec_backup_key(cluster_id: str) -> EntryKey: return (cluster_id, 'spec.smb') +def rgw_credentials_key(cluster_id: str, share_id: str) -> EntryKey: + """Return key identifying RGW credentials for a share in an external store.""" + return (cluster_id, f'rgw-creds.{share_id}.json') + + def tls_credential_key( cluster_id: str, tls_credential_id: str, cred_type: str ) -> EntryKey: diff --git a/src/pybind/mgr/smb/handler.py b/src/pybind/mgr/smb/handler.py index 10364024181..dc72afa880d 100644 --- a/src/pybind/mgr/smb/handler.py +++ b/src/pybind/mgr/smb/handler.py @@ -27,7 +27,7 @@ from ceph.deployment.service_spec import ( ) from ceph.fs.earmarking import EarmarkTopScope -from . import config_store, external, resources +from . import config_store, external, resources, rgw from .enums import ( AuthMode, CephFSStorageProvider, @@ -318,6 +318,7 @@ class ClusterConfigHandler: authorizer: Optional[AccessAuthorizer] = None, orch: Optional[OrchSubmitter] = None, earmark_resolver: Optional[EarmarkResolver] = None, + tool_execer: 'rgw.ToolExecer', ) -> None: self.internal_store = internal_store self.public_store = public_store @@ -332,6 +333,7 @@ class ClusterConfigHandler: if earmark_resolver is None: earmark_resolver = cast(EarmarkResolver, _FakeEarmarkResolver()) self._earmark_resolver = earmark_resolver + self._tool_execer = tool_execer log.info( 'Initialized new ClusterConfigHandler with' f' internal store {self.internal_store!r},' @@ -351,7 +353,7 @@ class ClusterConfigHandler: """ log.debug('applying changes to internal data store') results = ResultGroup() - staging = Staging(self.internal_store) + staging = Staging(self.internal_store, self._tool_execer) try: incoming = order_resources(inputs) for resource in incoming: @@ -639,18 +641,43 @@ class ClusterConfigHandler: assert isinstance(cluster, resources.Cluster) # vols: hold the cephfs volumes our shares touch. some operations are # disabled/skipped unless we touch volumes. - vols = {share.checked_cephfs.volume for share in change_group.shares} + vols = { + share.checked_cephfs.volume + for share in change_group.shares + if share.cephfs is not None + } + # rgw_buckets: hold the RGW buckets our shares touch + rgw_buckets = { + share.rgw.bucket + for share in change_group.shares + if share.rgw is not None + } # save the various object types previous_info = _swap_pending_cluster_info( self.public_store, change_group, - orch_needed=bool(vols and self._orch), + orch_needed=bool((vols or rgw_buckets) and self._orch), ) _save_pending_join_auths(self.priv_store, change_group) _save_pending_users_and_groups(self.priv_store, change_group) _save_pending_tls_credentials(self.priv_store, change_group) + _save_pending_rgw_credentials(self.priv_store, change_group) + rgw_credential_entries = { + share.share_id: change_group.cache[ + external.rgw_credentials_key( + change_group.cluster.cluster_id, share.share_id + ) + ] + for share in change_group.shares + if share.rgw is not None + and share.rgw.access_key_id + and share.rgw.secret_access_key + } cluster_conf = _ClusterConf.assemble( - change_group, self._path_resolver, self._authorizer + change_group, + self._path_resolver, + self._authorizer, + rgw_credential_entries, ) _save_pending_config(self.public_store, cluster_conf) # remove any stray objects @@ -689,6 +716,17 @@ class ClusterConfigHandler: ] for tc in change_group.tls_credentials } + rgw_credential_entries = { + share.share_id: change_group.cache[ + external.rgw_credentials_key( + cluster.cluster_id, share.share_id + ) + ] + for share in change_group.shares + if share.rgw is not None + and share.rgw.access_key_id + and share.rgw.secret_access_key + } ext_ceph_cluster = None if change_group.ext_ceph_clusters: assert len(change_group.ext_ceph_clusters) == 1 @@ -713,9 +751,31 @@ class ClusterConfigHandler: # via orch. This differs from NFS because ganesha embeds the cephx # keys directly in each export definition block while samba needs the # ceph keyring to load keys. + # For RGW shares, we don't need CephFS volumes but still need orchestration. previous_orch = previous_info.get('orch_needed', False) - if self._orch and (vols or previous_orch): + if self._orch and (vols or rgw_buckets or previous_orch): + log.debug( + 'Submitting SMB service spec for cluster %s: ' + 'shares=%d (cephfs_vols=%d, rgw_buckets=%d), ' + 'previous_orch=%s', + cluster.cluster_id, + len(change_group.shares), + len(vols), + len(rgw_buckets), + previous_orch, + ) self._orch.submit_smb_spec(smb_spec) + else: + log.debug( + 'Skipping SMB service orchestration for cluster %s: ' + 'orch_enabled=%s, shares=%d, cephfs_vols=%d, rgw_buckets=%d, previous_orch=%s', + cluster.cluster_id, + bool(self._orch), + len(change_group.shares), + len(vols), + len(rgw_buckets), + previous_orch, + ) def _remove_cluster(self, cluster_id: str) -> None: log.info('Removing cluster: %s', cluster_id) @@ -770,6 +830,8 @@ class _ShareConf: resolver: PathResolver cephx_entity: str ceph_cluster: str + rgw_access_key_uri: Optional[str] = None + rgw_secret_key_uri: Optional[str] = None @dataclasses.dataclass(frozen=True) @@ -785,6 +847,7 @@ class _ClusterConf: change_group: ClusterChangeGroup, default_resolver: PathResolver, authorizer: AccessAuthorizer, + rgw_credential_entries: Dict[str, ConfigEntry], ) -> Self: extcc = None assert isinstance(change_group.cluster, resources.Cluster) @@ -802,25 +865,36 @@ class _ClusterConf: ceph_cluster = 'exo' cephx_entity = checked(extcc.cluster).cephfs_user.name elif change_group.shares: - log.debug('local ceph cluster with shares') - cephx_entity = _cephx_data_entity(change_group.cluster) - # ensure an entity exists with access to the volumes - for share in change_group.shares: - authorizer.authorize_entity( - share.checked_cephfs.volume, cephx_entity + # Check if we have any CephFS shares that need CephX auth + has_cephfs_shares = any( + s.cephfs is not None for s in change_group.shares + ) + if has_cephfs_shares: + log.debug('local ceph cluster with CephFS shares') + cephx_entity = _cephx_data_entity(change_group.cluster) + # ensure an entity exists with access to the volumes (CephFS only) + for share in change_group.shares: + if share.cephfs: + authorizer.authorize_entity( + share.checked_cephfs.volume, cephx_entity + ) + cephadm_data_entity = cephx_entity + else: + log.debug( + 'local ceph cluster with RGW shares only (no CephX auth needed)' ) - cephadm_data_entity = cephx_entity else: log.debug('local cluster without shares: skipping ceph auth') return cls( change_group.cluster, [ - _ShareConf( + _make_share_conf( s, change_group.cluster, resolver, cephx_entity, ceph_cluster, + rgw_credential_entries, ) for s in change_group.shares ], @@ -829,6 +903,80 @@ class _ClusterConf: ) +def _make_share_conf( + s: resources.Share, + cluster: resources.Cluster, + resolver: PathResolver, + cephx_entity: str, + ceph_cluster: str, + rgw_credential_entries: Dict[str, ConfigEntry], +) -> _ShareConf: + creds_entry = rgw_credential_entries.get(s.share_id) + # Get the URI from the ConfigEntry object, not from the data + access_key_uri = ( + f'URI:{creds_entry.uri}:access_key_id' if creds_entry else None + ) + secret_key_uri = ( + f'URI:{creds_entry.uri}:secret_access_key' if creds_entry else None + ) + return _ShareConf( + s, + cluster, + resolver, + cephx_entity, + ceph_cluster, + rgw_access_key_uri=access_key_uri, + rgw_secret_key_uri=secret_key_uri, + ) + + +def _generate_rgw_share( + conf: _ShareConf, +) -> Dict[str, Dict[str, str]]: + """Generate Samba configuration for an RGW-backed share.""" + share = conf.resource + rgw = share.rgw + assert rgw is not None, "RGW storage configuration missing" + + # Use URI-based credential references (already formatted in _make_share_conf) + access_key_uri = conf.rgw_access_key_uri or '' + secret_key_uri = conf.rgw_secret_key_uri or '' + + cfg = { + # smb.conf options + 'options': { + 'path': '/', + 'vfs objects': 'ceph_rgw', + 'ceph_rgw:config_file': '/etc/ceph/ceph.conf', + 'ceph_rgw:keyring_file': '/etc/ceph/ceph.client.admin.keyring', + 'ceph_rgw:bucket': rgw.bucket, + 'ceph_rgw:user_id': rgw.user_id or '', + 'ceph_rgw:access_key': access_key_uri, + 'ceph_rgw:secret_access_key': secret_key_uri, + 'ceph_rgw:debug': 'off', + 'read only': ynbool(share.readonly), + 'browseable': ynbool(share.browseable), + 'kernel share modes': 'no', + 'smbd profiling share': 'yes', + } + } + + if share.comment is not None: + cfg['options']['comment'] = share.comment + if share.max_connections is not None: + cfg['options']['max connections'] = str(share.max_connections) + + # extend share with user+group login access lists + _generate_share_login_control(share, cfg) + _generate_share_hosts_access(share, cfg) + # extend share with custom options + custom_opts = share.cleaned_custom_smb_share_options + if custom_opts: + cfg['options'].update(custom_opts) + cfg['options']['x:ceph:has_custom_options'] = 'yes' + return cfg + + def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]: share = conf.resource cephx_entity = conf.cephx_entity @@ -1009,7 +1157,12 @@ def _generate_config(conf: _ClusterConf) -> Dict[str, Any]: _set_debug_level(cluster_global_opts, conf) share_configs = { - share.resource.name: _generate_share(share) for share in conf.shares + share.resource.name: ( + _generate_rgw_share(share) + if share.resource.rgw + else _generate_share(share) + ) + for share in conf.shares } instance_features = [] @@ -1309,6 +1462,31 @@ def _save_pending_tls_credentials( change_group.cache_updated_entry(tc_entry) +def _save_pending_rgw_credentials( + store: ConfigStore, + change_group: ClusterChangeGroup, +) -> None: + """Save RGW credentials for shares in the priv store.""" + cluster = change_group.cluster + assert isinstance(cluster, resources.Cluster) + + # Save credentials for each RGW share + for share in change_group.shares: + if share.rgw is not None: + # Only save if credentials are present + if share.rgw.access_key_id and share.rgw.secret_access_key: + ext_key = external.rgw_credentials_key( + cluster.cluster_id, share.share_id + ) + creds_entry = store[ext_key] + creds_data = { + 'access_key_id': share.rgw.access_key_id, + 'secret_access_key': share.rgw.secret_access_key, + } + creds_entry.set(creds_data) + change_group.cache_updated_entry(creds_entry) + + def _save_pending_config( store: ConfigStore, cluster_conf: _ClusterConf, @@ -1350,11 +1528,12 @@ def _store_transaction(store: ConfigStore) -> Iterator[None]: def _has_proxied_vfs(change_group: ClusterChangeGroup) -> bool: - """Return true if any shares in the change group use the new vfs module - with the proxied cephfs library. + """Return true if any CephFS-backed shares in the change group use the + new vfs module with the proxied cephfs library. """ return any( - s.checked_cephfs.provider.expand() + s.cephfs is not None + and s.checked_cephfs.provider.expand() == CephFSStorageProvider.SAMBA_VFS_PROXIED for s in change_group.shares ) diff --git a/src/pybind/mgr/smb/staging.py b/src/pybind/mgr/smb/staging.py index eda1c02b01a..aceb776cbfe 100644 --- a/src/pybind/mgr/smb/staging.py +++ b/src/pybind/mgr/smb/staging.py @@ -15,7 +15,7 @@ import operator from ceph.fs.earmarking import EarmarkTopScope -from . import config_store, resources +from . import config_store, resources, rgw from .enums import ( AuthMode, ConfigNS, @@ -54,8 +54,9 @@ class Staging: the destination store. """ - def __init__(self, store: ConfigStore) -> None: + def __init__(self, store: ConfigStore, tool_exec: rgw.ToolExecer) -> None: self.destination_store = store + self._tool_execer = tool_exec self.incoming: Dict[EntryKey, SMBResource] = {} self.deleted: Dict[EntryKey, SMBResource] = {} self._store_keycache: Set[EntryKey] = set() @@ -358,6 +359,63 @@ def _check_share_resource( msg="no matching cluster id", status={"cluster_id": share.cluster_id}, ) + + # Handle RGW shares - validate bucket existence and auto-fetch credentials + if share.rgw is not None: + # Validate bucket exists + if not rgw.validate_rgw_bucket( + staging._tool_execer, share.rgw.bucket + ): + raise ErrorResult( + share, + msg=f"RGW bucket '{share.rgw.bucket}' does not exist or is not accessible", + ) + + # Auto-fetch credentials if not provided + if ( + not share.rgw.user_id + or not share.rgw.access_key_id + or not share.rgw.secret_access_key + ): + try: + log.debug( + f"Auto-fetching RGW credentials for bucket {share.rgw.bucket}" + ) + ( + fetched_user_id, + access_key, + secret_key, + ) = rgw.fetch_rgw_credentials( + staging._tool_execer, + share.rgw.bucket, + share.rgw.user_id or '', + ) + # Update the share's RGW storage with fetched credentials + share.rgw = resources.RGWStorage( + bucket=share.rgw.bucket, + user_id=fetched_user_id, + access_key_id=access_key, + secret_access_key=secret_key, + ) + log.debug( + f"Successfully fetched credentials for user {fetched_user_id}" + ) + except ValueError as e: + raise ErrorResult( + share, + msg=f"Failed to fetch RGW credentials: {str(e)}", + ) + + name_used_by = _share_name_in_use(staging, share) + if name_used_by: + raise ErrorResult( + share, + msg="share name already in use", + status={"conflicting_share_id": name_used_by}, + ) + return + + # Handle CephFS shares assert share.cephfs is not None try: volpath = path_resolver.resolve_exists( From 9a80169005e2f8401b979f03dcaf07f7db903792 Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Tue, 26 May 2026 19:57:42 +0530 Subject: [PATCH 434/596] mgr/smb: Add test coverage for RGW share functionality Add test cases for RGW-backed SMB shares Key changes: - RGWStorage creation, validation, and serialization - Edge cases: None values, mutual exclusion Signed-off-by: Shweta Sodani Signed-off-by: Avan Thakkar --- .../mgr/cephadm/tests/services/test_smb.py | 2 + src/pybind/mgr/smb/tests/test_handler.py | 10 + src/pybind/mgr/smb/tests/test_resources.py | 305 +++++++++++ src/pybind/mgr/smb/tests/test_rgw_shares.py | 508 ++++++++++++++++++ 4 files changed, 825 insertions(+) create mode 100644 src/pybind/mgr/smb/tests/test_rgw_shares.py diff --git a/src/pybind/mgr/cephadm/tests/services/test_smb.py b/src/pybind/mgr/cephadm/tests/services/test_smb.py index 2add9e5fe6e..6c942096392 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_smb.py +++ b/src/pybind/mgr/cephadm/tests/services/test_smb.py @@ -55,6 +55,7 @@ class TestSMB: 'cluster_id': 'foxtrot', 'features': [], 'config_uri': 'rados://.smb/foxtrot/config.json', + 'extra_config_uris': ['rados:mon-config-key:smb/config/foxtrot/config.smb.rgw'], 'config': '', 'keyring': '[client.smb.config.tango.briskly]\nkey = None\n', 'config_auth_entity': 'client.smb.config.tango.briskly', @@ -125,6 +126,7 @@ class TestSMB: 'cluster_id': 'foxtrot', 'features': ['domain'], 'config_uri': 'rados://.smb/foxtrot/config2.json', + 'extra_config_uris': ['rados:mon-config-key:smb/config/foxtrot/config.smb.rgw'], 'join_sources': [ 'rados://.smb/foxtrot/join1.json', 'rados:mon-config-key:smb/config/foxtrot/join2.json', diff --git a/src/pybind/mgr/smb/tests/test_handler.py b/src/pybind/mgr/smb/tests/test_handler.py index 868958999f2..eb470cdc414 100644 --- a/src/pybind/mgr/smb/tests/test_handler.py +++ b/src/pybind/mgr/smb/tests/test_handler.py @@ -4,6 +4,14 @@ import smb from smb.handler import _FakeEarmarkResolver +class _FakeToolExecer: + """Mock tool executor for testing.""" + + def tool_exec(self, cmd: list[str]) -> tuple[int, str, str]: + """Mock tool_exec that returns success.""" + return (0, '{}', '') + + def _cluster(**kwargs): if 'clustering' not in kwargs: kwargs['clustering'] = smb.enums.SMBClustering.NEVER @@ -25,6 +33,8 @@ def thandler(): # into a single store. Do that to simplify testing a bit. public_store=ext_store, priv_store=ext_store, + mon_cmd_issuer=None, + tool_execer=_FakeToolExecer(), ) diff --git a/src/pybind/mgr/smb/tests/test_resources.py b/src/pybind/mgr/smb/tests/test_resources.py index a9987b2a7ce..dcbca7f0ac6 100644 --- a/src/pybind/mgr/smb/tests/test_resources.py +++ b/src/pybind/mgr/smb/tests/test_resources.py @@ -1283,3 +1283,308 @@ def test_share_qos_remove_individual_limit(): assert updated_cephfs.qos.write_iops_limit == 200 # Preserved assert updated_cephfs.qos.read_bw_limit == "10M" # Preserved assert updated_cephfs.qos.write_bw_limit == "20M" # Preserved + + +def test_rgw_storage_basic(): + """Test basic RGWStorage creation and validation.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: rgwcluster +share_id: rgwshare +name: RGW Share +rgw: + bucket: my-bucket +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + share = loaded[0] + assert isinstance(share.rgw, smb.resources.RGWStorage) + assert share.rgw.bucket == 'my-bucket' + assert share.rgw.user_id is None + assert share.rgw.credential_ref is None + + +def test_rgw_storage_with_credentials(): + """Test RGWStorage with credential_ref.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: rgwcluster +share_id: rgwshare +name: RGW Share +rgw: + bucket: my-bucket + user_id: testuser + credential_ref: testuser +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + share = loaded[0] + assert share.rgw.bucket == 'my-bucket' + assert share.rgw.user_id == 'testuser' + assert share.rgw.credential_ref == 'testuser' + + +def test_rgw_storage_missing_bucket(): + """Test validation error when bucket is missing.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: rgwcluster +share_id: rgwshare +name: RGW Share +rgw: + bucket: "" +""" + data = yaml.safe_load_all(yaml_str) + with pytest.raises(ValueError, match='bucket requires a value'): + smb.resources.load(data) + + +def test_rgw_storage_convert_mask(): + """Test RGWStorage conversion - credentials now in RGWCredential.""" + storage = smb.resources.RGWStorage( + bucket='my-bucket', + user_id='testuser', + credential_ref='testuser', + ) + + masked = storage.convert( + (smb.enums.PasswordFilter.NONE, smb.enums.PasswordFilter.HIDDEN) + ) + assert masked.bucket == 'my-bucket' + assert masked.user_id == 'testuser' + assert masked.credential_ref == 'testuser' + + +def test_rgw_storage_convert_none_credentials(): + """Test conversion when credential_ref is None.""" + storage = smb.resources.RGWStorage( + bucket='my-bucket', + ) + + encoded = storage.convert( + (smb.enums.PasswordFilter.NONE, smb.enums.PasswordFilter.BASE64) + ) + assert encoded.credential_ref is None + + masked = storage.convert( + (smb.enums.PasswordFilter.NONE, smb.enums.PasswordFilter.HIDDEN) + ) + assert masked.credential_ref is None + + +def test_rgw_storage_to_simplified(): + """Test RGWStorage serialization to simplified format.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: rgwcluster +share_id: rgwshare +name: RGW Share +rgw: + bucket: my-bucket + user_id: testuser + credential_ref: testuser +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + share = loaded[0] + simplified = share.to_simplified() + + assert 'rgw' in simplified + assert simplified['rgw']['bucket'] == 'my-bucket' + assert simplified['rgw']['user_id'] == 'testuser' + # Credentials are now referenced via credential_ref + assert simplified['rgw']['credential_ref'] == 'testuser' + + +def test_rgw_storage_invalid_credentials(): + """Test RGWStorage with invalid credential_ref.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: rgwcluster +share_id: rgwshare +name: RGW Share +rgw: + bucket: my-bucket + credential_ref: "invalid-ref-with-spaces" +""" + data = yaml.safe_load_all(yaml_str) + # Note: Credential validation happens during staging, not at load time + # This test documents that RGWStorage accepts any credential_ref string + loaded = smb.resources.load(data) + assert loaded + assert loaded[0].rgw.credential_ref == "invalid-ref-with-spaces" + + +def test_share_with_rgw_and_cephfs_mutual_exclusion(): + """Test that share cannot have both rgw and cephfs.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.share +cluster_id: testcluster +share_id: testshare +name: Invalid Share +cephfs: + volume: cephfs + path: / +rgw: + bucket: my-bucket + path: / +""" + data = yaml.safe_load_all(yaml_str) + with pytest.raises(ValueError, match='only one storage backend'): + smb.resources.load(data) + + +def test_rgw_credential_basic(): + """Test basic RGWCredential creation and validation.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +user_id: s3user +access_key_id: AKIAIOSFODNN7EXAMPLE +secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + cred = loaded[0] + assert isinstance(cred, smb.resources.RGWCredential) + assert cred.rgw_credential_id == 'rgwcred1' + assert cred.user_id == 's3user' + assert cred.access_key_id == 'AKIAIOSFODNN7EXAMPLE' + assert ( + cred.secret_access_key == 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ) + assert cred.intent == smb.enums.Intent.PRESENT + + +def test_rgw_credential_missing_required_fields(): + """Test that RGWCredential requires user_id, access_key_id, and secret_access_key.""" + import yaml + + # Missing user_id + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +access_key_id: AKIAIOSFODNN7EXAMPLE +secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +""" + data = yaml.safe_load_all(yaml_str) + with pytest.raises(Exception): # Will fail during construction + smb.resources.load(data) + + # Missing access_key_id + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +user_id: s3user +secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +""" + data = yaml.safe_load_all(yaml_str) + with pytest.raises(Exception): # Will fail during construction + smb.resources.load(data) + + # Missing secret_access_key + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +user_id: s3user +access_key_id: AKIAIOSFODNN7EXAMPLE +""" + data = yaml.safe_load_all(yaml_str) + with pytest.raises(Exception): # Will fail during construction + smb.resources.load(data) + + +def test_rgw_credential_password_conversion(): + """Test RGWCredential password field conversion.""" + cred = smb.resources.RGWCredential( + rgw_credential_id='test_cred', + user_id='testuser', + access_key_id='AKIATEST', + secret_access_key='secretkey123', + ) + + # Test masking (NONE -> HIDDEN) + masked = cred.convert( + (smb.enums.PasswordFilter.NONE, smb.enums.PasswordFilter.HIDDEN) + ) + assert masked.access_key_id == '****************' + assert masked.secret_access_key == '****************' + assert masked.user_id == 'testuser' # user_id should not be masked + + # Test base64 encoding (NONE -> BASE64) + encoded = cred.convert( + (smb.enums.PasswordFilter.NONE, smb.enums.PasswordFilter.BASE64) + ) + assert encoded.access_key_id != 'AKIATEST' + assert encoded.secret_access_key != 'secretkey123' + assert encoded.user_id == 'testuser' + + # Test base64 decoding (BASE64 -> NONE) + decoded = encoded.convert( + (smb.enums.PasswordFilter.BASE64, smb.enums.PasswordFilter.NONE) + ) + assert decoded.access_key_id == 'AKIATEST' + assert decoded.secret_access_key == 'secretkey123' + + +def test_rgw_credential_with_linked_cluster(): + """Test RGWCredential with linked_to_cluster.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +user_id: s3user +access_key_id: AKIAIOSFODNN7EXAMPLE +secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +linked_to_cluster: mysmb +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + cred = loaded[0] + assert cred.linked_to_cluster == 'mysmb' + + +def test_rgw_credential_removed(): + """Test RGWCredential with removed intent.""" + import yaml + + yaml_str = """ +resource_type: ceph.smb.rgw.credential +rgw_credential_id: rgwcred1 +user_id: placeholder +access_key_id: placeholder +secret_access_key: placeholder +intent: removed +""" + data = yaml.safe_load_all(yaml_str) + loaded = smb.resources.load(data) + assert loaded + + cred = loaded[0] + assert cred.intent == smb.enums.Intent.REMOVED + assert cred.rgw_credential_id == 'rgwcred1' diff --git a/src/pybind/mgr/smb/tests/test_rgw_shares.py b/src/pybind/mgr/smb/tests/test_rgw_shares.py new file mode 100644 index 00000000000..2983e81c57d --- /dev/null +++ b/src/pybind/mgr/smb/tests/test_rgw_shares.py @@ -0,0 +1,508 @@ +import json + +import pytest + +import smb +import smb.external + + +class _FakeToolExecer: + """Mock tool executor for testing RGW operations.""" + + def tool_exec(self, cmd: list[str]) -> tuple[int, str, str]: + """Mock tool_exec that returns success for RGW commands.""" + # Check if this is a radosgw-admin bucket stats command + if 'radosgw-admin' in cmd and 'bucket' in cmd and 'stats' in cmd: + # Return mock bucket stats with owner field + bucket_stats = json.dumps( + {'owner': 'testuser', 'bucket': 'my-bucket', 'usage': {}} + ) + return (0, bucket_stats, '') + # Check if this is a radosgw-admin user info command + if 'radosgw-admin' in cmd and 'user' in cmd and 'info' in cmd: + # Return mock user info with credentials + user_info = json.dumps( + { + 'user_id': 'testuser', + 'keys': [ + { + 'access_key': 'AUTO_FETCHED_ACCESS_KEY', + 'secret_key': 'AUTO_FETCHED_SECRET_KEY', + } + ], + } + ) + return (0, user_info, '') + # Default response for other commands + return (0, '{}', '') + + +def _cluster(**kwargs): + """Helper to create cluster with default clustering setting.""" + if 'clustering' not in kwargs: + kwargs['clustering'] = smb.enums.SMBClustering.NEVER + return smb.resources.Cluster(**kwargs) + + +@pytest.fixture +def thandler(): + """Fixture for RGW tests with RGW-aware tool executor.""" + ext_store = smb.config_store.MemConfigStore() + return smb.handler.ClusterConfigHandler( + internal_store=smb.config_store.MemConfigStore(), + public_store=ext_store, + priv_store=ext_store, + mon_cmd_issuer=None, + tool_execer=_FakeToolExecer(), + ) + + +def test_internal_apply_cluster_and_rgw_share(thandler): + """Test creating a cluster with an RGW share.""" + cluster = _cluster( + cluster_id='rgwtest', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='rgwtest', + share_id='rgwshare1', + name='RGW Share One', + rgw=smb.resources.RGWStorage( + bucket='my-bucket', + user_id='testuser', + ), + ) + rg = thandler.apply([cluster, share]) + assert rg.success, rg.to_simplified() + assert ('clusters', 'rgwtest') in thandler.internal_store.data + assert ('shares', 'rgwtest.rgwshare1') in thandler.internal_store.data + # Check that credential was auto-created + assert ('rgw_creds', 'testuser') in thandler.internal_store.data + + shares = thandler.share_ids() + assert len(shares) == 1 + assert ('rgwtest', 'rgwshare1') in shares + + # Verify share uses credential_ref + share_data = thandler.internal_store.data[('shares', 'rgwtest.rgwshare1')] + assert share_data['rgw']['credential_ref'] == 'testuser' + + +def test_apply_rgw_share_with_credentials(thandler): + """Test applying an RGW share with full credentials.""" + cluster = _cluster( + cluster_id='rgwcluster', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='rgwcluster', + share_id='s3share', + name='S3 Share', + rgw=smb.resources.RGWStorage( + bucket='test-bucket', + user_id='rgwuser', + ), + ) + rg = thandler.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Verify credential was auto-created with fetched credentials + assert ('rgw_creds', 'rgwuser') in thandler.internal_store.data + cred_dict = thandler.internal_store.data[('rgw_creds', 'rgwuser')] + assert cred_dict['user_id'] == 'rgwuser' + assert cred_dict['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred_dict['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + # Verify share uses credential_ref + share_dict = thandler.internal_store.data[ + ('shares', 'rgwcluster.s3share') + ] + assert share_dict['rgw']['bucket'] == 'test-bucket' + assert share_dict['rgw']['credential_ref'] == 'rgwuser' + + +def test_rgw_share_auto_fetch_credentials(thandler): + """Test RGW share with auto-fetched credentials (bucket only provided).""" + cluster = _cluster( + cluster_id='autofetch', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='autofetch', + share_id='autoshare', + name='Auto Fetch Share', + rgw=smb.resources.RGWStorage( + bucket='my-bucket', + ), + ) + rg = thandler.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Verify credential was auto-created with fetched credentials + assert ('rgw_creds', 'testuser') in thandler.internal_store.data + cred_dict = thandler.internal_store.data[('rgw_creds', 'testuser')] + assert cred_dict['user_id'] == 'testuser' + assert cred_dict['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred_dict['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + # Verify share uses credential_ref + share_dict = thandler.internal_store.data[ + ('shares', 'autofetch.autoshare') + ] + assert share_dict['rgw']['bucket'] == 'my-bucket' + assert share_dict['rgw']['credential_ref'] == 'testuser' + + +def test_rgw_share_remove(thandler): + """Test removing an RGW share.""" + # First create a cluster and RGW share + cluster = _cluster( + cluster_id='rgwtest', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='rgwtest', + share_id='rgwshare1', + name='RGW Share One', + rgw=smb.resources.RGWStorage( + bucket='my-bucket', + user_id='testuser', + ), + ) + rg = thandler.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Now remove the share + rmshare = smb.resources.RemovedShare( + cluster_id='rgwtest', + share_id='rgwshare1', + ) + rg = thandler.apply([rmshare]) + assert rg.success, rg.to_simplified() + + shares = thandler.share_ids() + assert len(shares) == 0 + assert ('shares', 'rgwtest.rgwshare1') not in thandler.internal_store.data + + +def test_rgw_share_minimal_config(thandler): + """Test RGW share with minimal configuration (bucket only).""" + cluster = _cluster( + cluster_id='minimalrgw', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='minimalrgw', + share_id='minimal', + name='Minimal RGW Share', + rgw=smb.resources.RGWStorage( + bucket='my-bucket', + ), + ) + rg = thandler.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Verify credential was auto-created with fetched credentials + assert ('rgw_creds', 'testuser') in thandler.internal_store.data + cred_dict = thandler.internal_store.data[('rgw_creds', 'testuser')] + assert cred_dict['user_id'] == 'testuser' + assert cred_dict['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred_dict['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + # Verify share uses credential_ref + share_dict = thandler.internal_store.data[ + ('shares', 'minimalrgw.minimal') + ] + assert share_dict['rgw']['bucket'] == 'my-bucket' + assert share_dict['rgw']['credential_ref'] == 'testuser' + + +def test_multiple_rgw_shares_same_cluster(thandler): + """Test creating multiple RGW shares in the same cluster.""" + cluster = _cluster( + cluster_id='multirgw', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share1 = smb.resources.Share( + cluster_id='multirgw', + share_id='share1', + name='RGW Share 1', + rgw=smb.resources.RGWStorage( + bucket='bucket1', + user_id='user1', + ), + ) + share2 = smb.resources.Share( + cluster_id='multirgw', + share_id='share2', + name='RGW Share 2', + rgw=smb.resources.RGWStorage( + bucket='bucket2', + user_id='user2', + ), + ) + rg = thandler.apply([cluster, share1, share2]) + assert rg.success, rg.to_simplified() + + shares = thandler.share_ids() + assert len(shares) == 2 + assert ('multirgw', 'share1') in shares + assert ('multirgw', 'share2') in shares + + # Verify credentials were auto-created for both users + assert ('rgw_creds', 'user1') in thandler.internal_store.data + assert ('rgw_creds', 'user2') in thandler.internal_store.data + + # Verify both shares use credential_ref + share1_dict = thandler.internal_store.data[('shares', 'multirgw.share1')] + share2_dict = thandler.internal_store.data[('shares', 'multirgw.share2')] + assert share1_dict['rgw']['bucket'] == 'bucket1' + assert share1_dict['rgw']['credential_ref'] == 'user1' + assert share2_dict['rgw']['bucket'] == 'bucket2' + assert share2_dict['rgw']['credential_ref'] == 'user2' + + +def test_rgw_credential_reuse_same_user(thandler): + """Test that multiple shares with same user_id reuse the same credential.""" + cluster = _cluster( + cluster_id='reusetest', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share1 = smb.resources.Share( + cluster_id='reusetest', + share_id='share1', + name='RGW Share 1', + rgw=smb.resources.RGWStorage( + bucket='bucket1', + user_id='shareduser', + ), + ) + share2 = smb.resources.Share( + cluster_id='reusetest', + share_id='share2', + name='RGW Share 2', + rgw=smb.resources.RGWStorage( + bucket='bucket2', + user_id='shareduser', + ), + ) + rg = thandler.apply([cluster, share1, share2]) + assert rg.success, rg.to_simplified() + + # Verify only ONE credential was created for the shared user + rgw_creds = [ + k for k in thandler.internal_store.data.keys() if k[0] == 'rgw_creds' + ] + assert len(rgw_creds) == 1 + assert ('rgw_creds', 'shareduser') in thandler.internal_store.data + + # Verify both shares reference the same credential + share1_dict = thandler.internal_store.data[('shares', 'reusetest.share1')] + share2_dict = thandler.internal_store.data[('shares', 'reusetest.share2')] + assert share1_dict['rgw']['credential_ref'] == 'shareduser' + assert share2_dict['rgw']['credential_ref'] == 'shareduser' + + +# ---- priv-store credential isolation tests ---- +# These tests use SEPARATE public and private stores so each can be +# independently inspected. The real credential values must +# only ever appear in the private store (via a config:merge stub), never in +# the public RADOS config. + + +@pytest.fixture +def split_handler(): + """Handler with separate public and private stores.""" + pub = smb.config_store.MemConfigStore() + priv = smb.config_store.MemConfigStore() + h = smb.handler.ClusterConfigHandler( + internal_store=smb.config_store.MemConfigStore(), + public_store=pub, + priv_store=priv, + tool_execer=_FakeToolExecer(), + ) + return h, pub, priv + + +def _rgw_cluster_and_share(cluster_id, share_id, share_name, **rgw_kwargs): + cluster = _cluster( + cluster_id=cluster_id, + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id=cluster_id, + share_id=share_id, + name=share_name, + rgw=smb.resources.RGWStorage(**rgw_kwargs), + ) + return cluster, share + + +def test_rgw_credentials_absent_from_public_config(split_handler): + """Credential values must be empty strings in the public RADOS config.""" + h, pub, priv = split_handler + cluster, share = _rgw_cluster_and_share( + 'c1', + 's1', + 'mybucket', + bucket='mybucket', + user_id='testuser', + ) + rg = h.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Verify credential was auto-created + assert ('rgw_creds', 'testuser') in h.internal_store.data + + pub_cfg = pub['c1', 'config.smb'].get() + opts = pub_cfg['shares']['mybucket']['options'] + assert opts['ceph_rgw:access_key'] == '' + assert opts['ceph_rgw:secret_access_key'] == '' + + +def test_rgw_credential_stub_written_to_priv_store(split_handler): + """Private store must hold config:merge stub with real credential values.""" + h, pub, priv = split_handler + cluster, share = _rgw_cluster_and_share( + 'c1', + 's1', + 'mybucket', + bucket='mybucket', + user_id='testuser', + ) + rg = h.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + # Verify credential was auto-created with fetched credentials + assert ('rgw_creds', 'testuser') in h.internal_store.data + cred_dict = h.internal_store.data[('rgw_creds', 'testuser')] + assert cred_dict['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred_dict['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + stub = priv['c1', 'config.smb.rgw'].get() + assert stub['samba-container-config'] == 'v0' + assert 'config:merge' in stub + opts = stub['config:merge']['shares']['mybucket']['options'] + assert opts['ceph_rgw:access_key'] == 'AUTO_FETCHED_ACCESS_KEY' + assert opts['ceph_rgw:secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + +def test_no_priv_store_entry_for_non_rgw_cluster(split_handler): + """A cluster with no RGW shares must not write a credential stub.""" + h, pub, priv = split_handler + cluster = _cluster( + cluster_id='cephfs1', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share = smb.resources.Share( + cluster_id='cephfs1', + share_id='fsshare', + name='FS Share', + cephfs=smb.resources.CephFSStorage( + volume='cephfs', + path='/', + ), + ) + rg = h.apply([cluster, share]) + assert rg.success, rg.to_simplified() + + stub_entry = priv['cephfs1', 'config.smb.rgw'] + assert not stub_entry.exists() + + +def test_multi_share_stub_covers_all_rgw_shares(split_handler): + """Credential stub must contain entries for every RGW share.""" + h, pub, priv = split_handler + cluster = _cluster( + cluster_id='multi', + auth_mode=smb.enums.AuthMode.USER, + user_group_settings=[ + smb.resources.UserGroupSource( + source_type=smb.resources.UserGroupSourceType.EMPTY, + ), + ], + ) + share1 = smb.resources.Share( + cluster_id='multi', + share_id='s1', + name='bucket1', + rgw=smb.resources.RGWStorage( + bucket='bucket1', + user_id='u1', + ), + ) + share2 = smb.resources.Share( + cluster_id='multi', + share_id='s2', + name='bucket2', + rgw=smb.resources.RGWStorage( + bucket='bucket2', + user_id='u2', + ), + ) + rg = h.apply([cluster, share1, share2]) + assert rg.success, rg.to_simplified() + + # Verify credentials were auto-created for both users with fetched credentials + assert ('rgw_creds', 'u1') in h.internal_store.data + assert ('rgw_creds', 'u2') in h.internal_store.data + cred1 = h.internal_store.data[('rgw_creds', 'u1')] + cred2 = h.internal_store.data[('rgw_creds', 'u2')] + assert cred1['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred1['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + assert cred2['access_key_id'] == 'AUTO_FETCHED_ACCESS_KEY' + assert cred2['secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + + stub = priv['multi', 'config.smb.rgw'].get() + merge_shares = stub['config:merge']['shares'] + b1opts = merge_shares['bucket1']['options'] + b2opts = merge_shares['bucket2']['options'] + assert b1opts['ceph_rgw:access_key'] == 'AUTO_FETCHED_ACCESS_KEY' + assert b1opts['ceph_rgw:secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' + assert b2opts['ceph_rgw:access_key'] == 'AUTO_FETCHED_ACCESS_KEY' + assert b2opts['ceph_rgw:secret_access_key'] == 'AUTO_FETCHED_SECRET_KEY' From 847dc5cc8c099fed82f1228e95051b4a30c4a4da Mon Sep 17 00:00:00 2001 From: Shweta Sodani Date: Wed, 27 May 2026 13:32:27 +0530 Subject: [PATCH 435/596] doc/mgr/smb: document new options for smb share create rgw Signed-off-by: Shweta Sodani --- doc/mgr/smb.rst | 124 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/doc/mgr/smb.rst b/doc/mgr/smb.rst index 6cf26482a58..4a3411fa5fe 100644 --- a/doc/mgr/smb.rst +++ b/doc/mgr/smb.rst @@ -382,6 +382,51 @@ Create a read-only share at a custom path in the CephFS volume: --path=/qbranch/top/secret/plans --readonly +Create RGW Share +++++++++++++++++ + +.. prompt:: bash # + + ceph smb share create rgw [--share-name=] [--user-id=] [--readonly] + +Create a new SMB share, hosted by the named cluster, that maps to a RADOS +Gateway (RGW) bucket. This allows S3-compatible object storage to be accessed +via the SMB protocol. + +Options: + +cluster_id + A short string uniquely identifying the cluster +share_id + A short string uniquely identifying the share +bucket + The name of the RGW bucket to be shared +share_name + Optional. The public name of the share, visible to clients. If not provided + the ``share_id`` will be used automatically +user_id + Optional. The RGW user ID that owns the bucket. If not provided, the system + will attempt to determine the bucket owner automatically +readonly + Creates a read-only share + +Examples +~~~~~~~~ + +Create a share for an RGW bucket: + +.. prompt:: bash # + + ceph smb share create rgw test1 photos my-photos-bucket + +Create a share with a custom name and specific user: + +.. prompt:: bash # + + ceph smb share create rgw test1 photos my-photos-bucket \ + --share-name="Photo Archive" --user-id=s3user + + .. _qos-parameters: QoS Parameters @@ -1099,7 +1144,8 @@ max_connections connections to a specific share. The default value is 0 and it indicates that there is no limit on the number of connections cephfs - Required object. Fields: + Object. Configures CephFS-backed storage for the share. Either a ``cephfs`` + or ``rgw`` object must be specified, but not both. Fields: volume Required string. Name of the cephfs volume to use @@ -1169,6 +1215,18 @@ cephfs name String. A value indicating what FSCrypt key to fetch. The specific value of the name depends on the scope being used. +rgw + Object. Configures RADOS Gateway (RGW) backed storage for the share. Either + a ``cephfs`` or ``rgw`` object must be specified, but not both. This allows + S3-compatible object storage to be accessed via the SMB protocol. Fields: + + bucket + Required string. The name of the RGW bucket to be shared. + user_id + Optional string. The RGW user ID that owns the bucket. If not provided, + the system will automatically determine the bucket owner and fetch the + necessary credentials. + restrict_access Optional boolean, defaulting to false. If true the share will only permit access by users explicitly listed in ``login_control``. @@ -1216,7 +1274,32 @@ custom_smb_share_options things in ways that the Ceph team can not help with. This special key will automatically be removed from the list of options passed to Samba. -The following is an example of a share with QoS settings including burst +The following is an example of an RGW-backed share with minimal configuration +(credentials auto-fetched): + +.. code-block:: yaml + + resource_type: ceph.smb.share + cluster_id: tango + share_id: s3share + name: "S3 Storage" + rgw: + bucket: my-bucket + +Another example of an RGW-backed share with explicit user_id: + +.. code-block:: yaml + + resource_type: ceph.smb.share + cluster_id: tango + share_id: s3share + name: "S3 Storage" + rgw: + bucket: my-bucket + user_id: s3user + + +The following is an example of a CephFS share with QoS settings including burst multipliers and human-readable bandwidth limits: .. code-block:: yaml @@ -1366,6 +1449,43 @@ Example: groups: [] +RGW Credential Resource +------------------------ + +An RGW credential resource stores RADOS Gateway (RGW) access credentials that can be used by RGW-backed shares to authenticate with the object storage system. + +A RGW credential resource supports the following fields: + +resource_type + A literal string ``ceph.smb.rgw.credential`` +rgw_credential_id + A short string identifying the RGW credential resource. +intent + One of ``present`` or ``removed``. If not provided, ``present`` is assumed. + If ``removed`` all following fields are optional +user_id + Required string. The RGW user ID that owns the credentials +access_key + Required string. The RGW access key for authentication +secret_key + Required string. The RGW secret key for authentication +linked_to_cluster: + Optional. A string containing a cluster ID. If set, the resource may only + be used with the linked cluster and will automatically be removed when the + linked cluster is removed. + + +Example: + +.. code-block:: yaml + + resource_type: ceph.smb.rgw.credential + rgw_credential_id: s3user + user_id: s3user + access_key: AKIAIOSFODNN7EXAMPLE + secret_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + + TLS Credential Resource ------------------------ From 8916aa85f73a6d3dabc62bfce4e029bff7e9322d Mon Sep 17 00:00:00 2001 From: Avan Thakkar Date: Thu, 11 Jun 2026 15:25:33 +0530 Subject: [PATCH 436/596] mgr/smb: add RGW credentials resource and store in priv_stor Refactored RGW credential management to use dedicated RGWCredential resources instead of embedding credentials directly in Share resources. This will help to reuse the same credential across multiple shares using same user. RGW credentials must not appear in the public RADOS store, which any client with pool caps can read. Instead, write a config:merge stub containing only the credential fields to the private mon config-key store, and pass its URI to the container alongside the primary config URI via extra_config_uris. - Public config: ceph_rgw:access_key / secret_access_key are empty strings - Private stub: stored under smb/config//config.smb.rgw, contains only the real credential values via config:merge - extra_config_uris: new Config field carrying supplementary URIs appended to SAMBACC_CONFIG after the primary config_uri Signed-off-by: Avan Thakkar Signed-off-by: Shweta Sodani --- src/cephadm/cephadmlib/daemons/smb.py | 4 + src/pybind/mgr/cephadm/services/smb.py | 19 ++ src/pybind/mgr/smb/enums.py | 1 + src/pybind/mgr/smb/external.py | 6 +- src/pybind/mgr/smb/handler.py | 301 +++++++++++++++---------- src/pybind/mgr/smb/internal.py | 18 ++ src/pybind/mgr/smb/module.py | 17 +- src/pybind/mgr/smb/resources.py | 63 ++++-- src/pybind/mgr/smb/rgw_auth.py | 50 ++++ src/pybind/mgr/smb/sqlite_store.py | 23 ++ src/pybind/mgr/smb/staging.py | 180 ++++++++++++--- 11 files changed, 510 insertions(+), 172 deletions(-) create mode 100644 src/pybind/mgr/smb/rgw_auth.py diff --git a/src/cephadm/cephadmlib/daemons/smb.py b/src/cephadm/cephadmlib/daemons/smb.py index 3026d6530b7..684cbbe495f 100644 --- a/src/cephadm/cephadmlib/daemons/smb.py +++ b/src/cephadm/cephadmlib/daemons/smb.py @@ -245,6 +245,7 @@ class Config: debug_delay: int = 0 join_sources: List[str] = dataclasses.field(default_factory=list) user_sources: List[str] = dataclasses.field(default_factory=list) + extra_config_uris: List[str] = dataclasses.field(default_factory=list) custom_dns: List[str] = dataclasses.field(default_factory=list) smb_port: int = 0 ctdb_port: int = 0 @@ -267,6 +268,7 @@ class Config: def config_uris(self) -> List[str]: uris = [self.source_config] + uris.extend(self.extra_config_uris or []) uris.extend(self.user_sources or []) if self.clustered: # When clustered, we inject certain clustering related config vars @@ -702,6 +704,7 @@ class SMB(ContainerDaemonForm): source_config = configs.get('config_uri', '') join_sources = configs.get('join_sources', []) user_sources = configs.get('user_sources', []) + extra_config_uris = configs.get('extra_config_uris', []) custom_dns = configs.get('custom_dns', []) instance_features = configs.get('features', []) files = data_utils.dict_get(configs, 'files', {}) @@ -768,6 +771,7 @@ class SMB(ContainerDaemonForm): source_config=source_config, join_sources=join_sources, user_sources=user_sources, + extra_config_uris=extra_config_uris, custom_dns=custom_dns, # major features domain_member=Features.DOMAIN.value in instance_features, diff --git a/src/pybind/mgr/cephadm/services/smb.py b/src/pybind/mgr/cephadm/services/smb.py index d6f31c8312a..f63730221eb 100644 --- a/src/pybind/mgr/cephadm/services/smb.py +++ b/src/pybind/mgr/cephadm/services/smb.py @@ -206,6 +206,16 @@ class SMBService(CephService): ca_cert=ssl_params.ssl_ca_cert or '', ) + def _rgw_creds_uri(self, cluster_id: str) -> Optional[str]: + from smb.external import rgw_config_key as _smb_rgw_config_key + from smb.mon_store import MonKeyConfigStore + _rgw_entry = MonKeyConfigStore(self.mgr)[ + _smb_rgw_config_key(cluster_id) + ] + if _rgw_entry.exists(): + return _rgw_entry.uri + return None + def generate_config( self, daemon_spec: CephadmDaemonDeploySpec ) -> Tuple[Dict[str, Any], List[str]]: @@ -220,6 +230,14 @@ class SMBService(CephService): config_blobs['cluster_id'] = smb_spec.cluster_id config_blobs['features'] = smb_spec.features config_blobs['config_uri'] = smb_spec.config_uri + # For RGW clusters, append the private-store config as an extra URI + # loaded after the public config. sambacc's config:merge is a general + # merge mechanism; here the mgr populates it with only the RGW + # credential fields, keeping the public config the primary source of + # truth. + rgw_creds_uri = self._rgw_creds_uri(smb_spec.cluster_id) + if rgw_creds_uri: + config_blobs['extra_config_uris'] = [rgw_creds_uri] _add_cfg(config_blobs, 'join_sources', smb_spec.join_sources) _add_cfg(config_blobs, 'user_sources', smb_spec.user_sources) _add_cfg(config_blobs, 'custom_dns', smb_spec.custom_dns) @@ -228,6 +246,7 @@ class SMBService(CephService): cluster_public_addrs = smb_spec.strict_cluster_ip_specs() _add_cfg(config_blobs, 'cluster_public_addrs', cluster_public_addrs) ceph_users = smb_spec.include_ceph_users or [] + config_blobs.update( self._ceph_config_and_keyring_for( smb_spec, daemon_spec.daemon_id, ceph_users diff --git a/src/pybind/mgr/smb/enums.py b/src/pybind/mgr/smb/enums.py index fac7ba54bbf..5b1850c9128 100644 --- a/src/pybind/mgr/smb/enums.py +++ b/src/pybind/mgr/smb/enums.py @@ -80,6 +80,7 @@ class ConfigNS(_StrEnum): USERS_AND_GROUPS = 'users_and_groups' JOIN_AUTHS = 'join_auths' TLS_CREDENTIALS = 'tls_creds' + RGW_CREDENTIALS = 'rgw_creds' EXTERNAL_CEPH_CLUSTERS = 'ext_ceph_clusters' diff --git a/src/pybind/mgr/smb/external.py b/src/pybind/mgr/smb/external.py index 8937411e28d..3eb5bb6b8ab 100644 --- a/src/pybind/mgr/smb/external.py +++ b/src/pybind/mgr/smb/external.py @@ -49,9 +49,9 @@ def spec_backup_key(cluster_id: str) -> EntryKey: return (cluster_id, 'spec.smb') -def rgw_credentials_key(cluster_id: str, share_id: str) -> EntryKey: - """Return key identifying RGW credentials for a share in an external store.""" - return (cluster_id, f'rgw-creds.{share_id}.json') +def rgw_config_key(cluster_id: str) -> EntryKey: + """Return key for the RGW credential stub in the private store.""" + return (cluster_id, 'config.smb.rgw') def tls_credential_key( diff --git a/src/pybind/mgr/smb/handler.py b/src/pybind/mgr/smb/handler.py index dc72afa880d..a6270243ba9 100644 --- a/src/pybind/mgr/smb/handler.py +++ b/src/pybind/mgr/smb/handler.py @@ -45,6 +45,7 @@ from .internal import ( CommonResourceEntry, ExternalCephClusterEntry, JoinAuthEntry, + RGWCredentialEntry, ShareEntry, TLSCredentialEntry, UsersAndGroupsEntry, @@ -55,6 +56,7 @@ from .proto import ( ConfigEntry, ConfigStore, EarmarkResolver, + MonCommandIssuer, OrchSubmitter, PathResolver, Self, @@ -62,11 +64,13 @@ from .proto import ( ) from .resources import SMBResource from .results import ErrorResult, Result, ResultGroup +from .rgw_auth import RGWAuthorizer from .staging import ( Staging, auth_refs, cross_check_resource, ext_cluster_refs, + rgw_credential_refs, tls_refs, ug_refs, ) @@ -99,6 +103,7 @@ class ClusterChangeGroup: join_auths: List[resources.JoinAuth], users_and_groups: List[resources.UsersAndGroups], tls_credentials: List[resources.TLSCredential], + rgw_credentials: List[resources.RGWCredential], ext_ceph_clusters: List[resources.ExternalCephCluster], ): self.cluster = cluster @@ -106,6 +111,7 @@ class ClusterChangeGroup: self.join_auths = join_auths self.users_and_groups = users_and_groups self.tls_credentials = tls_credentials + self.rgw_credentials = rgw_credentials self.ext_ceph_clusters = ext_ceph_clusters # a cache for modified entries self.cache = config_store.EntryCache() @@ -180,6 +186,15 @@ class _FakeAuthorizer: pass +class _FakeMonCommandIssuer: + """A stub MonCommandIssuer for unit testing.""" + + def mon_command( + self, cmd_dict: dict, inbuf: Optional[str] = None + ) -> Tuple[int, str, str]: + return (0, '', '') + + class _Matcher: _match_resources = ( resources.Cluster, @@ -187,6 +202,7 @@ class _Matcher: resources.JoinAuth, resources.UsersAndGroups, resources.TLSCredential, + resources.RGWCredential, resources.ExternalCephCluster, ) @@ -316,6 +332,7 @@ class ClusterConfigHandler: priv_store: ConfigStore, path_resolver: Optional[PathResolver] = None, authorizer: Optional[AccessAuthorizer] = None, + mon_cmd_issuer: Optional[MonCommandIssuer] = None, orch: Optional[OrchSubmitter] = None, earmark_resolver: Optional[EarmarkResolver] = None, tool_execer: 'rgw.ToolExecer', @@ -329,6 +346,9 @@ class ClusterConfigHandler: if authorizer is None: authorizer = _FakeAuthorizer() self._authorizer: AccessAuthorizer = authorizer + if mon_cmd_issuer is None: + mon_cmd_issuer = _FakeMonCommandIssuer() + self._mon_cmd_issuer: MonCommandIssuer = mon_cmd_issuer self._orch = orch # if None, disables updating the spec via orch if earmark_resolver is None: earmark_resolver = cast(EarmarkResolver, _FakeEarmarkResolver()) @@ -405,6 +425,9 @@ class ClusterConfigHandler: def user_and_group_ids(self) -> List[str]: return list(UsersAndGroupsEntry.ids(self.internal_store)) + def rgw_credential_ids(self) -> List[str]: + return list(RGWCredentialEntry.ids(self.internal_store)) + def all_resources(self) -> List[SMBResource]: with _store_transaction(self.internal_store): return self._search_resources(_Matcher()) @@ -536,13 +559,14 @@ class ClusterConfigHandler: removed_cluster_ids.add(cluster_id) continue present_cluster_ids.add(cluster_id) + cluster_shares = [ + self._share_entry(cid, shid).get_share() + for cid, shid in share_ids + if cid == cluster_id + ] change_group = ClusterChangeGroup( cluster, - [ - self._share_entry(cid, shid).get_share() - for cid, shid in share_ids - if cid == cluster_id - ], + cluster_shares, [ self._join_auth_entry(_id).get_join_auth() for _id in auth_refs(cluster) @@ -557,6 +581,12 @@ class ClusterConfigHandler: ).get_tls_credential() for _id in tls_refs(cluster) ], + [ + RGWCredentialEntry.from_store( + self.internal_store, _id + ).get_rgw_credential() + for _id in rgw_credential_refs(cluster_shares) + ], [ ExternalCephClusterEntry.from_store( self.internal_store, _id @@ -597,6 +627,7 @@ class ClusterConfigHandler: chg_join_ids: Set[str] = set() chg_ug_ids: Set[str] = set() chg_tls_ids: Set[str] = set() + chg_rgw_cred_ids: Set[str] = set() chg_extc_ids: Set[str] = set() for result in updated: state = (result.status or {}).get('state', None) @@ -618,13 +649,21 @@ class ClusterConfigHandler: chg_ug_ids.add(result.src.users_groups_id) elif isinstance(result.src, resources.TLSCredential): chg_tls_ids.add(result.src.tls_credential_id) + elif isinstance(result.src, resources.RGWCredential): + chg_rgw_cred_ids.add(result.src.rgw_credential_id) elif isinstance(result.src, resources.ExternalCephCluster): chg_extc_ids.add(result.src.external_ceph_cluster_id) # TODO: here's a lazy bit. if any join auths or users/groups changed we # will regen all clusters because these can be shared by >1 cluster. # In future, make this only pick clusters using the named resources. - if chg_join_ids or chg_ug_ids or chg_tls_ids or chg_extc_ids: + if ( + chg_join_ids + or chg_ug_ids + or chg_tls_ids + or chg_extc_ids + or chg_rgw_cred_ids + ): chg_cluster_ids.update(ClusterEntry.ids(self.internal_store)) return chg_cluster_ids @@ -661,25 +700,16 @@ class ClusterConfigHandler: _save_pending_join_auths(self.priv_store, change_group) _save_pending_users_and_groups(self.priv_store, change_group) _save_pending_tls_credentials(self.priv_store, change_group) - _save_pending_rgw_credentials(self.priv_store, change_group) - rgw_credential_entries = { - share.share_id: change_group.cache[ - external.rgw_credentials_key( - change_group.cluster.cluster_id, share.share_id - ) - ] - for share in change_group.shares - if share.rgw is not None - and share.rgw.access_key_id - and share.rgw.secret_access_key - } + # Create RGW authorizer for this cluster configuration + rgw_authorizer = RGWAuthorizer(self._mon_cmd_issuer) cluster_conf = _ClusterConf.assemble( change_group, self._path_resolver, self._authorizer, - rgw_credential_entries, + rgw_authorizer, ) _save_pending_config(self.public_store, cluster_conf) + _save_pending_rgw_config(self.priv_store, cluster_conf) # remove any stray objects external.rm_other_in_ns( self.priv_store, @@ -716,17 +746,6 @@ class ClusterConfigHandler: ] for tc in change_group.tls_credentials } - rgw_credential_entries = { - share.share_id: change_group.cache[ - external.rgw_credentials_key( - cluster.cluster_id, share.share_id - ) - ] - for share in change_group.shares - if share.rgw is not None - and share.rgw.access_key_id - and share.rgw.secret_access_key - } ext_ceph_cluster = None if change_group.ext_ceph_clusters: assert len(change_group.ext_ceph_clusters) == 1 @@ -738,7 +757,7 @@ class ClusterConfigHandler: join_source_entries=join_source_entries, user_source_entries=user_source_entries, tls_credential_entries=tls_credential_entries, - data_entity=cluster_conf.data_entity, + user_entities=cluster_conf.user_entities, needs_proxy=_has_proxied_vfs(change_group), ext_ceph_cluster=ext_ceph_cluster, ssl_certificates=ssl_certificates, @@ -830,8 +849,7 @@ class _ShareConf: resolver: PathResolver cephx_entity: str ceph_cluster: str - rgw_access_key_uri: Optional[str] = None - rgw_secret_key_uri: Optional[str] = None + rgw_entity: str = '' @dataclasses.dataclass(frozen=True) @@ -839,7 +857,7 @@ class _ClusterConf: resource: resources.Cluster shares: Iterable[_ShareConf] change_group: ClusterChangeGroup - data_entity: str + user_entities: List[str] @classmethod def assemble( @@ -847,7 +865,7 @@ class _ClusterConf: change_group: ClusterChangeGroup, default_resolver: PathResolver, authorizer: AccessAuthorizer, - rgw_credential_entries: Dict[str, ConfigEntry], + rgw_authorizer: RGWAuthorizer, ) -> Self: extcc = None assert isinstance(change_group.cluster, resources.Cluster) @@ -857,102 +875,99 @@ class _ClusterConf: resolver = default_resolver cephx_entity = '' # default to no entity for data access - cephadm_data_entity = '' # passed to cephadm service spec + cephfs_entity = '' # CephFS-specific entity + rgw_entity = '' # RGW-specific entity + cephadm_data_entities: List[ + str + ] = [] # passed to cephadm service spec ceph_cluster = '' # empty string means local cluster if extcc: log.debug('external ceph cluster') resolver = ExoResolver(change_group.cluster) ceph_cluster = 'exo' cephx_entity = checked(extcc.cluster).cephfs_user.name + cephfs_entity = cephx_entity + cephadm_data_entities = [cephx_entity] elif change_group.shares: # Check if we have any CephFS shares that need CephX auth has_cephfs_shares = any( s.cephfs is not None for s in change_group.shares ) + has_rgw_shares = any( + s.rgw is not None for s in change_group.shares + ) if has_cephfs_shares: log.debug('local ceph cluster with CephFS shares') - cephx_entity = _cephx_data_entity(change_group.cluster) + cephfs_entity = _cephx_data_entity(change_group.cluster) # ensure an entity exists with access to the volumes (CephFS only) for share in change_group.shares: if share.cephfs: authorizer.authorize_entity( - share.checked_cephfs.volume, cephx_entity + share.checked_cephfs.volume, cephfs_entity ) - cephadm_data_entity = cephx_entity - else: - log.debug( - 'local ceph cluster with RGW shares only (no CephX auth needed)' - ) + cephx_entity = cephfs_entity + cephadm_data_entities.append(cephfs_entity) + if has_rgw_shares: + log.debug('local ceph cluster with RGW shares') + # RGW shares need a CephX entity for RADOS access + rgw_entity = _cephx_rgw_entity(change_group.cluster) + # Authorize the entity using the provided RGW authorizer + rgw_authorizer.authorize_entity(rgw_entity) + cephadm_data_entities.append(rgw_entity) else: log.debug('local cluster without shares: skipping ceph auth') return cls( change_group.cluster, [ - _make_share_conf( + _ShareConf( s, change_group.cluster, resolver, - cephx_entity, + cephfs_entity, ceph_cluster, - rgw_credential_entries, + rgw_entity=rgw_entity, ) for s in change_group.shares ], change_group, - cephadm_data_entity, + cephadm_data_entities, ) -def _make_share_conf( - s: resources.Share, - cluster: resources.Cluster, - resolver: PathResolver, - cephx_entity: str, - ceph_cluster: str, - rgw_credential_entries: Dict[str, ConfigEntry], -) -> _ShareConf: - creds_entry = rgw_credential_entries.get(s.share_id) - # Get the URI from the ConfigEntry object, not from the data - access_key_uri = ( - f'URI:{creds_entry.uri}:access_key_id' if creds_entry else None - ) - secret_key_uri = ( - f'URI:{creds_entry.uri}:secret_access_key' if creds_entry else None - ) - return _ShareConf( - s, - cluster, - resolver, - cephx_entity, - ceph_cluster, - rgw_access_key_uri=access_key_uri, - rgw_secret_key_uri=secret_key_uri, - ) - - def _generate_rgw_share( conf: _ShareConf, + cred_map: Dict[str, resources.RGWCredential], ) -> Dict[str, Dict[str, str]]: """Generate Samba configuration for an RGW-backed share.""" share = conf.resource rgw = share.rgw assert rgw is not None, "RGW storage configuration missing" - # Use URI-based credential references (already formatted in _make_share_conf) - access_key_uri = conf.rgw_access_key_uri or '' - secret_key_uri = conf.rgw_secret_key_uri or '' + # Get user_id from credential (credential_ref is guaranteed by validation) + assert rgw.credential_ref is not None + cred = cred_map.get(rgw.credential_ref) + if cred is None: + log.error( + "share %r references missing RGW credential %r", + share.name, + rgw.credential_ref, + ) + user_id = "" + else: + user_id = cred.user_id or "" cfg = { # smb.conf options 'options': { 'path': '/', 'vfs objects': 'ceph_rgw', - 'ceph_rgw:config_file': '/etc/ceph/ceph.conf', - 'ceph_rgw:keyring_file': '/etc/ceph/ceph.client.admin.keyring', 'ceph_rgw:bucket': rgw.bucket, - 'ceph_rgw:user_id': rgw.user_id or '', - 'ceph_rgw:access_key': access_key_uri, - 'ceph_rgw:secret_access_key': secret_key_uri, + 'ceph_rgw:user_id': user_id, + # Credential values are left empty here; they are injected at + # deploy time via a config:merge stub in the private store so + # they never appear in the public RADOS config. + 'ceph_rgw:access_key': '', + 'ceph_rgw:secret_access_key': '', 'ceph_rgw:debug': 'off', 'read only': ynbool(share.readonly), 'browseable': ynbool(share.browseable), @@ -977,12 +992,7 @@ def _generate_rgw_share( return cfg -def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]: - share = conf.resource - cephx_entity = conf.cephx_entity - cephfs = share.checked_cephfs - assert cephfs.provider.is_vfs(), "not a vfs provider" - assert cephx_entity, "cephx entity name missing" +def cephx_stripped_entity(cephx_entity: str) -> str: # very annoyingly, samba's ceph module absolutely must NOT have the # "client." bit in front. JJM has been tripped up by this multiple times - # seemingly every time this module is touched. @@ -990,6 +1000,16 @@ def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]: plen = len(_prefix) if cephx_entity.startswith(_prefix): cephx_entity = cephx_entity[plen:] + return cephx_entity + + +def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]: + share = conf.resource + cephx_entity = conf.cephx_entity + cephfs = share.checked_cephfs + assert cephfs.provider.is_vfs(), "not a vfs provider" + assert cephx_entity, "cephx entity name missing" + cephx_entity = cephx_stripped_entity(cephx_entity) path = conf.resolver.resolve( cephfs.volume, cephfs.subvolumegroup, @@ -1156,9 +1176,30 @@ def _generate_config(conf: _ClusterConf) -> Dict[str, Any]: cluster_global_opts['smb ports'] = str(_smb_port(cluster)) _set_debug_level(cluster_global_opts, conf) + # Check if cluster has RGW shares and add global RGW options + has_rgw_shares = any(share.resource.rgw for share in conf.shares) + if has_rgw_shares: + # Get pre-calculated stripped RGW entity from first RGW share + rgw_entity = next( + (share.rgw_entity for share in conf.shares if share.resource.rgw), + '', + ) + if rgw_entity: + cluster_global_opts[ + 'ceph_rgw:config_file' + ] = '/etc/ceph/ceph.conf' + cluster_global_opts['ceph_rgw:keyring_file'] = '/etc/ceph/keyring' + cluster_global_opts['ceph_rgw:id'] = cephx_stripped_entity( + rgw_entity + ) + + cred_map = { + c.rgw_credential_id: c for c in conf.change_group.rgw_credentials + } + share_configs = { share.resource.name: ( - _generate_rgw_share(share) + _generate_rgw_share(share, cred_map) if share.resource.rgw else _generate_share(share) ) @@ -1215,7 +1256,7 @@ def _generate_smb_service_spec( join_source_entries: List[ConfigEntry], user_source_entries: List[ConfigEntry], tls_credential_entries: Dict[str, ConfigEntry], - data_entity: str = '', + user_entities: List[str], needs_proxy: bool = False, ext_ceph_cluster: Optional[resources.ExternalCephCluster], ssl_certificates: Dict[str, SSLParameters], @@ -1250,9 +1291,6 @@ def _generate_smb_service_spec( user_sources: List[str] = [] for entry in user_source_entries: user_sources.append(entry.uri) - user_entities: Optional[List[str]] = None - if data_entity: - user_entities = [data_entity] rc_cert = rc_key = rc_ca_cert = None if cluster.is_feature_enabled(_REMOTE_CONTROL): @@ -1462,31 +1500,6 @@ def _save_pending_tls_credentials( change_group.cache_updated_entry(tc_entry) -def _save_pending_rgw_credentials( - store: ConfigStore, - change_group: ClusterChangeGroup, -) -> None: - """Save RGW credentials for shares in the priv store.""" - cluster = change_group.cluster - assert isinstance(cluster, resources.Cluster) - - # Save credentials for each RGW share - for share in change_group.shares: - if share.rgw is not None: - # Only save if credentials are present - if share.rgw.access_key_id and share.rgw.secret_access_key: - ext_key = external.rgw_credentials_key( - cluster.cluster_id, share.share_id - ) - creds_entry = store[ext_key] - creds_data = { - 'access_key_id': share.rgw.access_key_id, - 'secret_access_key': share.rgw.secret_access_key, - } - creds_entry.set(creds_data) - change_group.cache_updated_entry(creds_entry) - - def _save_pending_config( store: ConfigStore, cluster_conf: _ClusterConf, @@ -1498,6 +1511,49 @@ def _save_pending_config( cluster_conf.change_group.cache_updated_entry(centry) +def _save_pending_rgw_config( + store: ConfigStore, + cluster_conf: _ClusterConf, +) -> None: + """Save an RGW credential stub to the private store for RGW clusters. + + Writes a stub using sambacc's config:merge mechanism, which merges the + provided JSON on top of the primary config at load time. The mgr + populates the stub with only the RGW credential fields here; everything else + stays in the public RADOS config. The stub URI is passed to the container + via extra_config_uris so credentials never appear in the public pool. + """ + cluster_id = cluster_conf.resource.cluster_id + rgw_shares = [s for s in cluster_conf.shares if s.resource.rgw] + if not rgw_shares: + return + cred_map = { + c.rgw_credential_id: c + for c in cluster_conf.change_group.rgw_credentials + } + merge_shares: Dict[str, Any] = {} + for sc in rgw_shares: + rgw = sc.resource.rgw + assert rgw is not None + assert rgw.credential_ref is not None + cred = cred_map[rgw.credential_ref] + access_key = cred.access_key_id or '' + secret_key = cred.secret_access_key or '' + merge_shares[sc.resource.name] = { + 'options': { + 'ceph_rgw:access_key': access_key, + 'ceph_rgw:secret_access_key': secret_key, + } + } + stub = { + 'samba-container-config': 'v0', + 'config:merge': {'shares': merge_shares}, + } + centry = store[external.rgw_config_key(cluster_id)] + centry.set(stub) + cluster_conf.change_group.cache_updated_entry(centry) + + def _save_pending_spec_backup( store: ConfigStore, change_group: ClusterChangeGroup, smb_spec: SMBSpec ) -> None: @@ -1515,6 +1571,15 @@ def _cephx_data_entity(cluster: resources.Cluster) -> str: return f'client.smb.fs.cluster.{cluster.cluster_id}' +def _cephx_rgw_entity(cluster: resources.Cluster) -> str: + """Generate a name for the cephx key that a cluster (smbd) will + use for RGW data access. + """ + if cluster.external_ceph_cluster: + return '' + return f'client.smb.rgw.cluster.{cluster.cluster_id}' + + @contextlib.contextmanager def _store_transaction(store: ConfigStore) -> Iterator[None]: transaction = getattr(store, 'transaction', None) diff --git a/src/pybind/mgr/smb/internal.py b/src/pybind/mgr/smb/internal.py index a954ebc782d..8b16826ea25 100644 --- a/src/pybind/mgr/smb/internal.py +++ b/src/pybind/mgr/smb/internal.py @@ -223,6 +223,23 @@ class TLSCredentialEntry(CommonResourceEntry): return self.get_resource_type(resources.TLSCredential) +class RGWCredentialEntry(CommonResourceEntry): + """RGWCredentialEntry resource getter/setter for the smb internal data + store(s). + """ + + namespace = ConfigNS.RGW_CREDENTIALS + _for_resource = resources.RGWCredential + + @classmethod + def to_key(cls, resource: SMBResource) -> ResourceKey: + assert isinstance(resource, cls._for_resource) + return ResourceIDKey(resource.rgw_credential_id) + + def get_rgw_credential(self) -> resources.RGWCredential: + return self.get_resource_type(resources.RGWCredential) + + class ExternalCephClusterEntry(CommonResourceEntry): """ExternalCephCluster resource getter/setter for internal store.""" @@ -251,6 +268,7 @@ def map_resource_entry( resources.JoinAuth: JoinAuthEntry, resources.UsersAndGroups: UsersAndGroupsEntry, resources.TLSCredential: TLSCredentialEntry, + resources.RGWCredential: RGWCredentialEntry, resources.ExternalCephCluster: ExternalCephClusterEntry, } try: diff --git a/src/pybind/mgr/smb/module.py b/src/pybind/mgr/smb/module.py index 185d1b1718d..5392a12621b 100644 --- a/src/pybind/mgr/smb/module.py +++ b/src/pybind/mgr/smb/module.py @@ -100,6 +100,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): public_store=self._public_store, path_resolver=path_resolver, authorizer=authorizer, + mon_cmd_issuer=self, orch=self._orch_backend(enable_orch=uo), earmark_resolver=earmark_resolver, tool_execer=self, @@ -630,12 +631,12 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): """Create an SMB share backed by RGW""" try: # Pass 'self' which conforms to ToolExecer protocol - ( - fetched_user_id, - access_key, - secret_key, - ) = rgw.fetch_rgw_credentials(self, bucket, user_id) + fetched_user_id = rgw.fetch_rgw_credentials( + self, bucket, user_id + )[0] + # Create share with user credentials + # The staging layer will auto-create the credential if needed share = resources.Share( cluster_id=cluster_id, share_id=share_id, @@ -644,11 +645,11 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule): rgw=resources.RGWStorage( bucket=bucket, user_id=fetched_user_id, - access_key_id=access_key, - secret_access_key=secret_key, ), ) - return self._apply_res([share], create_only=True).one() + + # Apply share resource (staging may create credential too) + return self._apply_res([share], create_only=True).squash(share) except ValueError as e: # Create a minimal share resource for error reporting error_share = resources.Share( diff --git a/src/pybind/mgr/smb/resources.py b/src/pybind/mgr/smb/resources.py index 118a40fa813..66d2e7585f5 100644 --- a/src/pybind/mgr/smb/resources.py +++ b/src/pybind/mgr/smb/resources.py @@ -406,8 +406,7 @@ class RGWStorage(_RBase): bucket: str user_id: Optional[str] = None - access_key_id: Optional[str] = None - secret_access_key: Optional[str] = None + credential_ref: Optional[str] = None def validate(self) -> None: if not self.bucket: @@ -418,23 +417,12 @@ class RGWStorage(_RBase): return self.__class__( bucket=self.bucket, user_id=self.user_id, - access_key_id=( - _password_convert(self.access_key_id, operation) - if self.access_key_id - else None - ), - secret_access_key=( - _password_convert(self.secret_access_key, operation) - if self.secret_access_key - else None - ), + credential_ref=self.credential_ref, ) @resourcelib.customize def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource: rc.user_id.quiet = True - rc.access_key_id.quiet = True - rc.secret_access_key.quiet = True return rc @@ -1199,6 +1187,52 @@ class TLSCredential(_RBase): return self +@resourcelib.resource('ceph.smb.rgw.credential') +class RGWCredential(_RBase): + """Contains RGW user credentials that can be referenced by multiple + SMB shares backed by RGW buckets. + """ + + rgw_credential_id: str + user_id: str + access_key_id: str + secret_access_key: str + intent: Intent = Intent.PRESENT + linked_to_cluster: Optional[str] = None + + def validate(self) -> None: + if not self.rgw_credential_id: + raise ValueError('rgw_credential_id requires a value') + validation.check_id(self.rgw_credential_id) + if self.linked_to_cluster is not None: + validation.check_id(self.linked_to_cluster) + if self.intent is Intent.PRESENT: + if not self.user_id: + raise ValueError('user_id must be specified') + if not self.access_key_id: + raise ValueError('access_key_id must be specified') + if not self.secret_access_key: + raise ValueError('secret_access_key must be specified') + + @resourcelib.customize + def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource: + rc.linked_to_cluster.quiet = True + rc.on_construction_error(InvalidResourceError.wrap) + return rc + + def convert(self, operation: ConversionOp) -> Self: + return self.__class__( + rgw_credential_id=self.rgw_credential_id, + intent=self.intent, + user_id=self.user_id, + access_key_id=_password_convert(self.access_key_id, operation), + secret_access_key=_password_convert( + self.secret_access_key, operation + ), + linked_to_cluster=self.linked_to_cluster, + ) + + @resourcelib.component() class CephUserKey(_RBase): """A Ceph User Key name and value pair.""" @@ -1243,6 +1277,7 @@ SMBResource = Union[ Share, UsersAndGroups, TLSCredential, + RGWCredential, ExternalCephCluster, ] diff --git a/src/pybind/mgr/smb/rgw_auth.py b/src/pybind/mgr/smb/rgw_auth.py new file mode 100644 index 00000000000..14929f17765 --- /dev/null +++ b/src/pybind/mgr/smb/rgw_auth.py @@ -0,0 +1,50 @@ +"""RGW authorization utilities for SMB.""" + +from typing import List, Optional + +import logging + +from .proto import MonCommandIssuer + +log = logging.getLogger(__name__) + + +class RGWAuthorizationGrantError(ValueError): + pass + + +class RGWAuthorizer: + """Using the rados APIs provided by the ceph mgr, authorize cephx users for + RGW access via RADOS. + """ + + def __init__(self, mc: MonCommandIssuer) -> None: + self._mc = mc + + def authorize_entity( + self, entity: str, caps: Optional[List[str]] = None + ) -> None: + """Create or update a CephX entity with RGW access capabilities.""" + + assert entity.startswith('client.') + + # Default caps for RGW access via RADOS + # These allow the SMB daemon to access RGW data through RADOS + if not caps: + caps = [ + 'mon', + 'allow r', + 'osd', + 'allow rwx tag rgw *=*', + ] + + cmd = { + 'prefix': 'auth get-or-create', + 'entity': entity, + 'caps': caps, + } + log.info('Requesting RGW authorization: %r', cmd) + ret, _, status = self._mc.mon_command(cmd) + if ret != 0: + raise RGWAuthorizationGrantError(status) + log.info('RGW authorization request success: %r', status) diff --git a/src/pybind/mgr/smb/sqlite_store.py b/src/pybind/mgr/smb/sqlite_store.py index 0f01aa5f5b0..39e2de03e5e 100644 --- a/src/pybind/mgr/smb/sqlite_store.py +++ b/src/pybind/mgr/smb/sqlite_store.py @@ -535,6 +535,22 @@ class MirrorExternalCephCluster(Mirror): return filtered +class MirrorRGWCredentials(Mirror): + """Mirroring configuration for objects in the rgw_credentials namespace.""" + + def __init__(self, store: ConfigStore) -> None: + super().__init__('rgw_credentials', store) + + def filter_object(self, obj: Simplified) -> Simplified: + """Filter rgw_credential for sqlite3 store.""" + filtered = copy.deepcopy(obj) + if filtered.get('access_key_id'): + filtered.pop('access_key_id', None) + if filtered.get('secret_access_key'): + filtered.pop('secret_access_key', None) + return filtered + + def _tables( *, specialize: bool = True, @@ -556,6 +572,7 @@ def _tables( SimpleTable('join_auths', 'join_auths'), SimpleTable('users_and_groups', 'users_and_groups'), SimpleTable('tls_creds', 'tls_creds'), + SimpleTable('rgw_creds', 'rgw_creds'), SimpleTable('ext_ceph_clusters', 'ext_ceph_clusters'), ] @@ -582,6 +599,10 @@ def _mirror_external_ceph_clusters( return (opts or {}).get('mirror_external_ceph_clusters') != 'no' +def _mirror_rgw_credentials(opts: Optional[Dict[str, str]] = None) -> bool: + return (opts or {}).get('mirror_rgw_credentials') != 'no' + + def mgr_sqlite3_db( mgr: Any, opts: Optional[Dict[str, str]] = None ) -> SqliteStore: @@ -611,6 +632,8 @@ def mgr_sqlite3_db_with_mirroring( mirrors.append(MirrorTLSCredentials(mirror_store)) if _mirror_external_ceph_clusters(opts): mirrors.append(MirrorExternalCephCluster(mirror_store)) + if _mirror_rgw_credentials(opts): + mirrors.append(MirrorRGWCredentials(mirror_store)) return SqliteMirroringStore(mgr, tables, mirrors) diff --git a/src/pybind/mgr/smb/staging.py b/src/pybind/mgr/smb/staging.py index aceb776cbfe..87f96890a66 100644 --- a/src/pybind/mgr/smb/staging.py +++ b/src/pybind/mgr/smb/staging.py @@ -30,6 +30,7 @@ from .internal import ( ClusterEntry, JoinAuthEntry, ResourceEntry, + RGWCredentialEntry, ShareEntry, TLSCredentialEntry, UsersAndGroupsEntry, @@ -127,6 +128,18 @@ class Staging: self.destination_store, ug_id ).get_users_and_groups() + def get_rgw_credential( + self, rgw_credential_id: str + ) -> resources.RGWCredential: + ekey = (str(RGWCredentialEntry.namespace), rgw_credential_id) + if ekey in self.incoming: + res = self.incoming[ekey] + assert isinstance(res, resources.RGWCredential) + return res + return RGWCredentialEntry.from_store( + self.destination_store, rgw_credential_id + ).get_rgw_credential() + def save(self) -> ResultGroup: results = ResultGroup() for res in self.deleted.values(): @@ -167,6 +180,7 @@ class Staging: self._prune(cids, JoinAuthEntry, resources.JoinAuth) self._prune(cids, UsersAndGroupsEntry, resources.UsersAndGroups) self._prune(cids, TLSCredentialEntry, resources.TLSCredential) + self._prune(cids, RGWCredentialEntry, resources.RGWCredential) def auth_refs(cluster: resources.Cluster) -> Collection[str]: @@ -360,27 +374,12 @@ def _check_share_resource( status={"cluster_id": share.cluster_id}, ) - # Handle RGW shares - validate bucket existence and auto-fetch credentials + # Handle RGW shares if share.rgw is not None: - # Validate bucket exists - if not rgw.validate_rgw_bucket( - staging._tool_execer, share.rgw.bucket - ): - raise ErrorResult( - share, - msg=f"RGW bucket '{share.rgw.bucket}' does not exist or is not accessible", - ) - - # Auto-fetch credentials if not provided - if ( - not share.rgw.user_id - or not share.rgw.access_key_id - or not share.rgw.secret_access_key - ): + # If credential_ref is not provided, auto-create credential + if not share.rgw.credential_ref: + # Fetch credentials from RGW try: - log.debug( - f"Auto-fetching RGW credentials for bucket {share.rgw.bucket}" - ) ( fetched_user_id, access_key, @@ -390,22 +389,81 @@ def _check_share_resource( share.rgw.bucket, share.rgw.user_id or '', ) - # Update the share's RGW storage with fetched credentials - share.rgw = resources.RGWStorage( - bucket=share.rgw.bucket, - user_id=fetched_user_id, - access_key_id=access_key, - secret_access_key=secret_key, - ) - log.debug( - f"Successfully fetched credentials for user {fetched_user_id}" - ) except ValueError as e: raise ErrorResult( share, msg=f"Failed to fetch RGW credentials: {str(e)}", ) + # Create credential resource automatically + # Use user_id as credential_id (linked to cluster via linked_to_cluster field) + credential_id = fetched_user_id + + # Check if credential already exists + try: + cred = staging.get_rgw_credential(credential_id) + # Credential exists, validate it's linked to correct cluster + if ( + cred.linked_to_cluster + and cred.linked_to_cluster != share.cluster_id + ): + raise ErrorResult( + share, + msg='RGW credential is linked to a different cluster', + status={ + 'credential_ref': credential_id, + 'other_cluster_id': cred.linked_to_cluster, + }, + ) + except KeyError: + # Credential doesn't exist, create it + cred = resources.RGWCredential( + rgw_credential_id=credential_id, + user_id=fetched_user_id, + access_key_id=access_key, + secret_access_key=secret_key, + linked_to_cluster=share.cluster_id, + ) + # Stage the credential + staging.stage(cred) + + # Update share to use credential_ref + share.rgw = resources.RGWStorage( + bucket=share.rgw.bucket, + credential_ref=credential_id, + ) + else: + # Validate existing credential_ref + try: + cred = staging.get_rgw_credential(share.rgw.credential_ref) + except KeyError: + raise ErrorResult( + share, + msg=f"RGW credential '{share.rgw.credential_ref}' not found", + status={"credential_ref": share.rgw.credential_ref}, + ) + + if ( + cred.linked_to_cluster + and cred.linked_to_cluster != share.cluster_id + ): + raise ErrorResult( + share, + msg='RGW credential is linked to a different cluster', + status={ + 'credential_ref': share.rgw.credential_ref, + 'other_cluster_id': cred.linked_to_cluster, + }, + ) + # Validate bucket exists + if not rgw.validate_rgw_bucket( + staging._tool_execer, share.rgw.bucket + ): + raise ErrorResult( + share, + msg=f"RGW bucket '{share.rgw.bucket}' does not exist or is not accessible", + ) + name_used_by = _share_name_in_use(staging, share) if name_used_by: raise ErrorResult( @@ -677,6 +735,59 @@ def _check_tls_credential_present( ) +@cross_check_resource.register +def _check_rgw_credential_resource( + resource: resources.RGWCredential, staging: Staging, **_kw: Any +) -> None: + """Check that the RGW credential resource can be updated.""" + if resource.intent == Intent.PRESENT: + return _check_rgw_credential_present(resource, staging) + return _check_rgw_credential_removed(resource, staging) + + +def _check_rgw_credential_removed( + rgw_cred: resources.RGWCredential, staging: Staging +) -> None: + refs_in_use: Dict[str, List[str]] = {} + for cid, sid in ShareEntry.ids(staging): + try: + share = ShareEntry.from_store( + staging.destination_store, cid, sid + ).get_share() + except KeyError: + continue + if ( + share.rgw + and share.rgw.credential_ref == rgw_cred.rgw_credential_id + ): + refs_in_use.setdefault(rgw_cred.rgw_credential_id, []).append( + f'{cid}.{sid}' + ) + if rgw_cred.rgw_credential_id in refs_in_use: + raise ErrorResult( + rgw_cred, + msg='RGW credential resource in use by shares', + status={ + 'shares': refs_in_use[rgw_cred.rgw_credential_id], + }, + ) + + +def _check_rgw_credential_present( + rgw_cred: resources.RGWCredential, staging: Staging +) -> None: + if rgw_cred.linked_to_cluster: + cids = set(ClusterEntry.ids(staging)) + if rgw_cred.linked_to_cluster not in cids: + raise ErrorResult( + rgw_cred, + msg='linked_to_cluster id not valid', + status={ + 'unknown_id': rgw_cred.linked_to_cluster, + }, + ) + + @cross_check_resource.register def _check_external_ceph_cluster_resource( ext_cluster: resources.ExternalCephCluster, staging: Staging, **_: Any @@ -753,6 +864,17 @@ def tls_refs(cluster: resources.Cluster) -> Collection[str]: return _remotectl_tls_refs(cluster) | _keybridge_tls_refs(cluster) +def rgw_credential_refs( + shares: List[resources.Share], +) -> Collection[str]: + """Return all credential_ref IDs used by RGW-backed shares.""" + return { + share.rgw.credential_ref + for share in shares + if share.rgw is not None and share.rgw.credential_ref + } + + def _keybridge_ids( cluster: resources.Cluster, ) -> Dict[str, resources.KeyBridgeScopeIdentity]: From b992ef63d1f0bbdd07c7ffa4380575473b4e8080 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Tue, 14 Apr 2026 18:13:59 +0200 Subject: [PATCH 437/596] tools: add missing includes Signed-off-by: Max Kellermann --- src/tools/ceph-dencoder/denc_registry.h | 1 + src/tools/ceph_monstore_tool.cc | 1 + src/tools/cephfs/Dumper.cc | 1 + src/tools/cephfs/MDSUtility.cc | 1 + src/tools/cephfs/Resetter.cc | 1 + src/tools/cephfs_mirror/PeerReplayer.h | 7 +++++++ src/tools/cephfs_mirror/Types.cc | 2 ++ src/tools/cephfs_mirror/Types.h | 4 +++- src/tools/monmaptool.cc | 1 + src/tools/rbd_mirror/ImageReplayer.cc | 1 + 10 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tools/ceph-dencoder/denc_registry.h b/src/tools/ceph-dencoder/denc_registry.h index 6b152a4b234..1b6496ab3a4 100644 --- a/src/tools/ceph-dencoder/denc_registry.h +++ b/src/tools/ceph-dencoder/denc_registry.h @@ -9,6 +9,7 @@ #include #include "include/buffer_fwd.h" +#include "global/global_context.h" // for g_ceph_context #include "msg/Message.h" namespace ceph { diff --git a/src/tools/ceph_monstore_tool.cc b/src/tools/ceph_monstore_tool.cc index 31ed80882d3..a84639aedd7 100644 --- a/src/tools/ceph_monstore_tool.cc +++ b/src/tools/ceph_monstore_tool.cc @@ -37,6 +37,7 @@ #include "osd/OSDMap.h" #include "crush/CrushCompiler.h" #include "mon/CreatingPGs.h" +#include "mon/mon_types.h" // for CEPH_MON_ONDISK_MAGIC namespace po = boost::program_options; diff --git a/src/tools/cephfs/Dumper.cc b/src/tools/cephfs/Dumper.cc index cf4dbf2d876..ce823050dd0 100644 --- a/src/tools/cephfs/Dumper.cc +++ b/src/tools/cephfs/Dumper.cc @@ -17,6 +17,7 @@ #include "include/compat.h" #include "include/fs_types.h" +#include "common/Cond.h" #include "common/debug.h" #include "common/entity_name.h" #include "common/errno.h" diff --git a/src/tools/cephfs/MDSUtility.cc b/src/tools/cephfs/MDSUtility.cc index 2790d3e1fab..62acb065994 100644 --- a/src/tools/cephfs/MDSUtility.cc +++ b/src/tools/cephfs/MDSUtility.cc @@ -14,6 +14,7 @@ #include "MDSUtility.h" #include "mon/MonClient.h" +#include "common/Cond.h" #include "common/debug.h" #define dout_context g_ceph_context diff --git a/src/tools/cephfs/Resetter.cc b/src/tools/cephfs/Resetter.cc index 3d51dbe8a8a..46ea8e86be9 100644 --- a/src/tools/cephfs/Resetter.cc +++ b/src/tools/cephfs/Resetter.cc @@ -16,6 +16,7 @@ #include "Resetter.h" #include +#include "common/Cond.h" #include "common/debug.h" #include "common/errno.h" #include "osdc/Journaler.h" diff --git a/src/tools/cephfs_mirror/PeerReplayer.h b/src/tools/cephfs_mirror/PeerReplayer.h index 7339df9e7bf..60cd81239df 100644 --- a/src/tools/cephfs_mirror/PeerReplayer.h +++ b/src/tools/cephfs_mirror/PeerReplayer.h @@ -11,8 +11,15 @@ #include "Types.h" #include "json_spirit/json_spirit.h" +#include +#include +#include +#include #include #include +#include +#include + #include namespace cephfs { diff --git a/src/tools/cephfs_mirror/Types.cc b/src/tools/cephfs_mirror/Types.cc index 651e3632b6e..ffc8ed0de38 100644 --- a/src/tools/cephfs_mirror/Types.cc +++ b/src/tools/cephfs_mirror/Types.cc @@ -3,6 +3,8 @@ #include "Types.h" +#include + namespace cephfs { namespace mirror { diff --git a/src/tools/cephfs_mirror/Types.h b/src/tools/cephfs_mirror/Types.h index 8c6fda4efa7..5f51e69cd2f 100644 --- a/src/tools/cephfs_mirror/Types.h +++ b/src/tools/cephfs_mirror/Types.h @@ -4,8 +4,10 @@ #ifndef CEPHFS_MIRROR_TYPES_H #define CEPHFS_MIRROR_TYPES_H +#include #include -#include +#include +#include #include #include diff --git a/src/tools/monmaptool.cc b/src/tools/monmaptool.cc index 8e56b7c4fe7..069869bd5c1 100644 --- a/src/tools/monmaptool.cc +++ b/src/tools/monmaptool.cc @@ -23,6 +23,7 @@ #include "global/global_init.h" #include "include/str_list.h" #include "mon/MonMap.h" +#include "mon/mon_types.h" // for ceph::features::mon::* using std::cerr; using std::cout; diff --git a/src/tools/rbd_mirror/ImageReplayer.cc b/src/tools/rbd_mirror/ImageReplayer.cc index 9aee9a96b4d..56a91712202 100644 --- a/src/tools/rbd_mirror/ImageReplayer.cc +++ b/src/tools/rbd_mirror/ImageReplayer.cc @@ -4,6 +4,7 @@ #include "include/compat.h" #include "common/Formatter.h" #include "common/admin_socket.h" +#include "common/Cond.h" #include "common/debug.h" #include "common/errno.h" #include "include/stringify.h" From 8ae44f6a3753bf5a1681887bb41705f313c47ff9 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Fri, 26 Jun 2026 08:44:35 +0800 Subject: [PATCH 438/596] mgr: fix PyObject leak in the remote() pickle calls dispatch_remote() and ceph_dispatch_remote() invoke pickle.loads/dumps via PyObject_CallMethodObjArgs(pmodule, PyUnicode_FromString("loads"), ...). The method-name string created by PyUnicode_FromString is a new reference that CallMethodObjArgs does not steal, and it was never released, so every cross -module remote() call leaked three PyUnicode objects on the active mgr. Use PyObject_CallMethod(pmodule, "loads", "(O)", arg), which takes the method name as a C string and manages it internally, so there is nothing to leak. The "(O)" format wraps the argument in a one-tuple so it reaches the callee as a single object. A bare "O" passes the argument through directly, and when it is a tuple (as remote()'s *args always is, including the empty tuple from a no-argument call) PyObject_CallMethod unpacks it and calls dumps() with the wrong arguments, e.g. dumps() with none. Fixes: https://tracker.ceph.com/issues/77724 Signed-off-by: Kefu Chai --- src/mgr/ActivePyModule.cc | 18 +++--------------- src/mgr/BaseMgrModule.cc | 18 +++--------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/src/mgr/ActivePyModule.cc b/src/mgr/ActivePyModule.cc index a01233ac5dc..24e91082278 100644 --- a/src/mgr/ActivePyModule.cc +++ b/src/mgr/ActivePyModule.cc @@ -155,11 +155,7 @@ std::optional> ActivePyModule::dispatch_remote( auto pmodule = py_module->pPickleModule; auto pickled_args_bytes = py_bytes_from_span(pickled_args); - auto args = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("loads"), - pickled_args_bytes, - nullptr); + auto args = PyObject_CallMethod(pmodule, "loads", "(O)", pickled_args_bytes); Py_DECREF(pickled_args_bytes); if (args == nullptr) { std::string caller = "ActivePyModule::dispatch_remote "s + method; @@ -169,11 +165,7 @@ std::optional> ActivePyModule::dispatch_remote( } auto pickled_kwargs_bytes = py_bytes_from_span(pickled_kwargs); - auto kwargs = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("loads"), - pickled_kwargs_bytes, - nullptr); + auto kwargs = PyObject_CallMethod(pmodule, "loads", "(O)", pickled_kwargs_bytes); Py_DECREF(pickled_kwargs_bytes); if (kwargs == nullptr) { std::string caller = "ActivePyModule::dispatch_remote "s + method; @@ -208,11 +200,7 @@ std::optional> ActivePyModule::dispatch_remote( } dout(20) << "Success calling '" << method << "'" << dendl; - auto pickled_ret = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("dumps"), - ret, - nullptr); + auto pickled_ret = PyObject_CallMethod(pmodule, "dumps", "(O)", ret); Py_DECREF(ret); if (pickled_ret == nullptr) { std::string caller = "ActivePyModule::dispatch_remote "s + method; diff --git a/src/mgr/BaseMgrModule.cc b/src/mgr/BaseMgrModule.cc index 8e34159aec7..11459d87938 100644 --- a/src/mgr/BaseMgrModule.cc +++ b/src/mgr/BaseMgrModule.cc @@ -871,11 +871,7 @@ ceph_dispatch_remote(BaseMgrModule *self, PyObject *args) } auto pmodule = self->this_module->py_module->pPickleModule; - auto pickled_args = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("dumps"), - remote_args, - nullptr); + auto pickled_args = PyObject_CallMethod(pmodule, "dumps", "(O)", remote_args); if (pickled_args == nullptr) { std::string caller = "ceph_dispatch_remote "s + " " + method; std::string err = handle_pyerror(true, other_module, caller); @@ -885,11 +881,7 @@ ceph_dispatch_remote(BaseMgrModule *self, PyObject *args) } std::span pickled_args_span = py_bytes_as_span(pickled_args); - auto pickled_kwargs = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("dumps"), - remote_kwargs, - nullptr); + auto pickled_kwargs = PyObject_CallMethod(pmodule, "dumps", "(O)", remote_kwargs); if (pickled_kwargs == nullptr) { std::string caller = "ceph_dispatch_remote "s + " " + method; std::string err = handle_pyerror(true, other_module, caller); @@ -940,11 +932,7 @@ ceph_dispatch_remote(BaseMgrModule *self, PyObject *args) } auto pickled_ret_bytes = py_bytes_from_vec(*maybe_pickled_ret); - auto ret = PyObject_CallMethodObjArgs( - pmodule, - PyUnicode_FromString("loads"), - pickled_ret_bytes, - nullptr); + auto ret = PyObject_CallMethod(pmodule, "loads", "(O)", pickled_ret_bytes); if (ret == nullptr) { std::string caller = "ceph_dispatch_remote "s + " " + method; std::string err = handle_pyerror(true, other_module, caller); From 216d468a1ad16b67feede2a932ee015556e5ca69 Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Thu, 25 Jun 2026 19:50:36 +0800 Subject: [PATCH 439/596] cmake: build fio from upstream 3.42 fio-3.42 is the latest stable release. We switched to the ceph/fio fork in 10baab3fc8293b8c30ca90a4acd76f70d011f1b5 to pick up a clang build fix; that fix has since landed upstream, so switch back. This also gives riscv64 proper arch support (arch-riscv64.h, added in fio-3.36), which the fork's fio-3.27-cxx lacks. Signed-off-by: Sun Yuechi --- cmake/modules/BuildFIO.cmake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmake/modules/BuildFIO.cmake b/cmake/modules/BuildFIO.cmake index 453d8ca2fba..61c445628f5 100644 --- a/cmake/modules/BuildFIO.cmake +++ b/cmake/modules/BuildFIO.cmake @@ -30,10 +30,10 @@ function(build_fio) file(MAKE_DIRECTORY ${source_dir}) ExternalProject_Add(fio_ext UPDATE_COMMAND "" # this disables rebuild on each run - GIT_REPOSITORY "https://github.com/ceph/fio.git" + GIT_REPOSITORY "https://github.com/axboe/fio.git" GIT_CONFIG advice.detachedHead=false GIT_SHALLOW 1 - GIT_TAG "fio-3.27-cxx" + GIT_TAG "fio-3.42" SOURCE_DIR ${source_dir} BUILD_IN_SOURCE 1 CONFIGURE_COMMAND /configure From 90e0541ec230b5ac758de9693299ade108f74c9e Mon Sep 17 00:00:00 2001 From: pujashahu Date: Fri, 12 Jun 2026 00:19:40 +0530 Subject: [PATCH 440/596] =?UTF-8?q?mgr/dashboard:=20NVMe-oF=20=E2=80=93=20?= =?UTF-8?q?Updated=20subtitle=20text=20on=20the=20Create=20Gateway=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes :https://tracker.ceph.com/issues/77361 Signed-off-by: pujaoshahu --- .../mgr/dashboard/frontend/src/app/ceph/block/block.module.ts | 2 +- .../block/nvmeof-group-form/nvmeof-group-form.component.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index cb9b96abcd8..b0852ec9b6b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -366,7 +366,7 @@ const routes: Routes = [ breadcrumbs: ActionLabels.CREATE, pageHeader: { title: $localize`Create Gateway Group`, - description: $localize`A logical group of gateways that hosts will connect to.` + description: $localize`A logical group of NVMe gateways that hosts connect to for load-balanced access.` } } }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html index 27a5de86421..a5fba7df131 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html @@ -10,7 +10,7 @@
+ class="form-heading form-item cds-mt-5">
Gateway group name From 4c790d72da61bd4331bb5845ae42a6f1d0454ad1 Mon Sep 17 00:00:00 2001 From: pujashahu Date: Mon, 15 Jun 2026 23:47:32 +0530 Subject: [PATCH 441/596] mgr/dashboard : Implement dependent resource handling for gateway Deletion Fixes: https://tracker.ceph.com/issues/77403 Signed-off-by: pujaoshahu --- .../src/app/ceph/block/block.module.ts | 4 +- ...ay-group-delete-guard-modal.component.html | 31 ++++++++++ ...ay-group-delete-guard-modal.component.scss | 0 ...group-delete-guard-modal.component.spec.ts | 55 ++++++++++++++++ ...eway-group-delete-guard-modal.component.ts | 29 +++++++++ .../nvmeof-gateway-group.component.html | 1 + .../nvmeof-gateway-group.component.spec.ts | 62 ++++++++++++++++++- .../nvmeof-gateway-group.component.ts | 35 +++++++++-- 8 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index cb9b96abcd8..442c79a1d3d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -105,6 +105,7 @@ import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view/nvme- import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem-view.component'; import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performance/nvmeof-subsystem-performance.component'; import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; +import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component'; @NgModule({ imports: [ @@ -194,7 +195,8 @@ import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; NvmeofSubsystemsStepFourComponent, NvmeofSubsystemOverviewComponent, NvmeofSubsystemPerformanceComponent, - NvmeofTabsComponent + NvmeofTabsComponent, + NvmeofGatewayGroupDeleteGuardModalComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html new file mode 100644 index 00000000000..433265ed544 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html @@ -0,0 +1,31 @@ + + +

Manage {{ gatewayName }}

+ Can't delete {{ gatewayName }} +
+
+

+ This resource has connected items that must be deleted first. Delete the connected items, and try again. +

+

View connected items:

+
+ @for (sub of connectedSubsystems; track sub.nqn) { + + } +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts new file mode 100644 index 00000000000..ff7ab9076b4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component'; + +describe('NvmeofGatewayGroupDeleteGuardModalComponent', () => { + let component: NvmeofGatewayGroupDeleteGuardModalComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [NvmeofGatewayGroupDeleteGuardModalComponent], + providers: [ + { provide: 'gatewayName', useValue: 'gateway-dev' }, + { + provide: 'connectedSubsystems', + useValue: [{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }] + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofGatewayGroupDeleteGuardModalComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + jest.spyOn(router, 'navigate').mockImplementation(); + fixture.detectChanges(); + }); + + it('should create the modal component', () => { + expect(component).toBeTruthy(); + }); + + it('should load dynamic inputs', () => { + expect(component.gatewayName).toBe('gateway-dev'); + expect(component.connectedSubsystems).toEqual([{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }]); + }); + + it('should navigate to subsystem detail page and close modal', () => { + const closeSpy = jest.spyOn(component, 'closeModal').mockImplementation(); + + component.navigateToSubsystem('subsystem-1'); + + expect(router.navigate).toHaveBeenCalledWith( + ['/block/nvmeof/subsystems', 'subsystem-1', 'overview'], + { queryParams: { group: 'gateway-dev' } } + ); + expect(closeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts new file mode 100644 index 00000000000..86cfd478a44 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts @@ -0,0 +1,29 @@ +import { Component, Inject, Optional } from '@angular/core'; +import { Router } from '@angular/router'; +import { BaseModal } from 'carbon-components-angular'; + +interface ConnectedSubsystem { + nqn: string; +} + +@Component({ + selector: 'cd-nvmeof-gateway-group-delete-guard-modal', + templateUrl: './nvmeof-gateway-group-delete-guard-modal.component.html', + standalone: false +}) +export class NvmeofGatewayGroupDeleteGuardModalComponent extends BaseModal { + constructor( + private router: Router, + @Optional() @Inject('gatewayName') public gatewayName: string, + @Optional() @Inject('connectedSubsystems') public connectedSubsystems: ConnectedSubsystem[] = [] + ) { + super(); + } + + navigateToSubsystem(nqn: string): void { + this.router.navigate(['/block/nvmeof/subsystems', nqn, 'overview'], { + queryParams: { group: this.gatewayName } + }); + this.closeModal(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html index 7f48a42cbf6..eae963ce641 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html @@ -56,6 +56,7 @@ }
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts index 9ea34f16bd5..69fad9acbfd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts @@ -1,10 +1,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group.component'; -import { GridModule, TabsModule } from 'carbon-components-angular'; +import { GridModule, TabsModule, ModalModule } from 'carbon-components-angular'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { of } from 'rxjs'; import { HttpClientModule } from '@angular/common/http'; import { SharedModule } from '~/app/shared/shared.module'; +import { Router } from '@angular/router'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component'; describe('NvmeofGatewayGroupComponent', () => { let component: NvmeofGatewayGroupComponent; @@ -18,9 +23,20 @@ describe('NvmeofGatewayGroupComponent', () => { }; await TestBed.configureTestingModule({ - imports: [HttpClientModule, SharedModule, TabsModule, GridModule], + imports: [HttpClientModule, SharedModule, TabsModule, GridModule, ModalModule], declarations: [NvmeofGatewayGroupComponent], - providers: [{ provide: NvmeofService, useValue: nvmeofServiceSpy }] + providers: [ + { provide: NvmeofService, useValue: nvmeofServiceSpy }, + { + provide: Router, + useValue: { navigate: jest.fn() } + }, + { + provide: ModalCdsService, + useValue: { show: jest.fn() } + } + ], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(NvmeofGatewayGroupComponent); @@ -242,4 +258,44 @@ describe('NvmeofGatewayGroupComponent', () => { done(); }); }); + + describe('Delete Flow with/without Subsystems', () => { + let mockGroup: any; + + beforeEach(() => { + mockGroup = { + service_name: 'nvmeof.rbd.default', + spec: { group: 'default' }, + subSystemCount: 0 + }; + component.selection.first = jest.fn().mockReturnValue(mockGroup); + }); + + it('should show can-not-delete modal if subsystems exist', () => { + const mockSubsystems = [{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }]; + nvmeofService.listSubsystems.mockReturnValue(of(mockSubsystems)); + const modalService = TestBed.inject(ModalCdsService); + + component.deleteGatewayGroupModal(); + + expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('default'); + expect(modalService.show).toHaveBeenCalledWith(NvmeofGatewayGroupDeleteGuardModalComponent, { + gatewayName: 'default', + connectedSubsystems: [{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }] + }); + }); + + it('should show delete confirmation modal if no subsystems exist', () => { + nvmeofService.listSubsystems.mockReturnValue(of([])); + const modalService = TestBed.inject(ModalCdsService); + + component.deleteGatewayGroupModal(); + + expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('default'); + expect(modalService.show).toHaveBeenCalledWith( + DeleteConfirmationModalComponent, + expect.any(Object) + ); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index 3ccef418bd0..933a085a1ff 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -13,7 +13,7 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { Icons, IconSize } from '~/app/shared/enum/icons.enum'; -import { NvmeofGatewayGroup } from '~/app/shared/models/nvmeof'; +import { NvmeofGatewayGroup, NvmeofSubsystem } from '~/app/shared/models/nvmeof'; import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; @@ -24,6 +24,7 @@ import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impa import { NotificationService } from '~/app/shared/services/notification.service'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component'; const BASE_URL = 'block/nvmeof/gateways'; @@ -194,16 +195,42 @@ export class NvmeofGatewayGroupComponent implements OnInit { spec: { group } } = selectedGroup; - const disableForm = selectedGroup.subSystemCount > 0 || !group; + if (!group) { + return; + } + // Fetch actual subsystem list to decide which modal to show + this.nvmeofService + .listSubsystems(group) + .pipe(catchError(() => of([]))) + .subscribe((subsystems: NvmeofSubsystem[]) => { + let subsList: NvmeofSubsystem[] = []; + if (subsystems) { + const rawList = Array.isArray(subsystems) ? subsystems : [subsystems]; + subsList = rawList.filter((subsystem: NvmeofSubsystem) => subsystem && subsystem.nqn); + } + + if (subsList.length > 0) { + this.modalService.show(NvmeofGatewayGroupDeleteGuardModalComponent, { + gatewayName: group, + connectedSubsystems: subsList.map((subsystem: NvmeofSubsystem) => ({ + nqn: subsystem.nqn + })) + }); + } else { + // No subsystems — show the regular delete confirmation modal + this.showDeleteConfirmationModal(selectedGroup, serviceName); + } + }); + } + + private showDeleteConfirmationModal(selectedGroup: CephServiceSpec, serviceName: string) { this.modalService.show(DeleteConfirmationModalComponent, { impact: DeletionImpact.high, itemDescription: $localize`gateway group`, bodyTemplate: this.deleteTpl, itemNames: [selectedGroup.spec.group], bodyContext: { - disableForm, - subsystemCount: selectedGroup.subSystemCount, deletionMessage: $localize`Deleting ${selectedGroup.spec.group} will remove all associated subsystems and may disrupt traffic routing for services relying on it. This action cannot be undone.` }, submitActionObservable: () => { From 1c65a8afa54d3f5200cc5776c4ce3ce237a19e3c Mon Sep 17 00:00:00 2001 From: pujashahu Date: Thu, 11 Jun 2026 23:35:20 +0530 Subject: [PATCH 442/596] mgr/dashboard: NVME- Remove block pool from create gateway page Adds unit test for creating gateway service with encryption when enabled. Fixes: https://tracker.ceph.com/issues/77359 Signed-off-by: pujashahu --- .../nvmeof-group-form.component.html | 34 +----- .../nvmeof-group-form.component.spec.ts | 101 ++++++------------ .../nvmeof-group-form.component.ts | 33 +----- .../service-form/service-form.component.html | 39 ------- .../service-form.component.spec.ts | 49 ++------- .../service-form/service-form.component.ts | 21 +--- 6 files changed, 41 insertions(+), 236 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html index 27a5de86421..4be2ead948b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html @@ -50,39 +50,7 @@
- -
-
- - - - - - - This field is required. - -
-
- +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts index 9af7f2ff76f..5b60872cd1b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts @@ -1,7 +1,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; @@ -14,7 +14,6 @@ import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofGroupFormComponent } from './nvmeof-group-form.component'; import { CheckboxModule, GridModule, InputModule, SelectModule } from 'carbon-components-angular'; -import { PoolService } from '~/app/shared/api/pool.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; import { FormHelper } from '~/testing/unit-test-helper'; @@ -24,17 +23,10 @@ describe('NvmeofGroupFormComponent', () => { let fixture: ComponentFixture; let form: CdFormGroup; let formHelper: FormHelper; - let poolService: PoolService; let taskWrapperService: TaskWrapperService; let cephServiceService: CephServiceService; let router: Router; - const mockPools = [ - { pool_name: 'rbd', application_metadata: ['rbd'] }, - { pool_name: 'rbd', application_metadata: ['rbd'] }, - { pool_name: 'pool2', application_metadata: ['rgw'] } - ]; - beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NvmeofGroupFormComponent], @@ -55,13 +47,10 @@ describe('NvmeofGroupFormComponent', () => { fixture = TestBed.createComponent(NvmeofGroupFormComponent); component = fixture.componentInstance; - poolService = TestBed.inject(PoolService); taskWrapperService = TestBed.inject(TaskWrapperService); cephServiceService = TestBed.inject(CephServiceService); router = TestBed.inject(Router); - spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools)); - component.ngOnInit(); form = component.groupForm; formHelper = new FormHelper(form); @@ -75,6 +64,7 @@ describe('NvmeofGroupFormComponent', () => { it('should initialize form with empty fields', () => { expect(form.controls.groupName.value).toBeNull(); expect(form.controls.unmanaged.value).toBe(false); + expect(form.controls.enableEncryption.value).toBe(false); }); it('should set action to CREATE on init', () => { @@ -91,57 +81,15 @@ describe('NvmeofGroupFormComponent', () => { formHelper.expectError('groupName', 'required'); }); - it('should require pool', () => { - formHelper.setValue('pool', null); - formHelper.expectError('pool', 'required'); - }); - - it('should be valid when groupName and pool are set', () => { + it('should be valid when groupName is set', () => { formHelper.setValue('groupName', 'test-group'); - formHelper.setValue('pool', 'rbd'); - expect(form.valid).toBe(true); + expect(form.controls.groupName.valid).toBe(true); }); - }); - describe('loadPools', () => { - it('should load pools and filter by rbd application metadata', fakeAsync(() => { - component.loadPools(); - tick(); - expect(component.pools.length).toBe(2); - expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']); - })); - - it('should set default pool to rbd if available', fakeAsync(() => { - component.groupForm.get('pool').setValue(null); - component.loadPools(); - tick(); - expect(component.groupForm.get('pool').value).toBe('rbd'); - })); - - it('should set first pool if rbd is not available', fakeAsync(() => { - component.groupForm.get('pool').setValue(null); - const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }]; - (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd)); - component.loadPools(); - tick(); - expect(component.groupForm.get('pool').value).toBe('custom-pool'); - })); - - it('should handle empty pools', fakeAsync(() => { - (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([])); - component.loadPools(); - tick(); - expect(component.pools.length).toBe(0); - expect(component.poolsLoading).toBe(false); - })); - - it('should handle pool loading error', fakeAsync(() => { - (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error')); - component.loadPools(); - tick(); - expect(component.pools).toEqual([]); - expect(component.poolsLoading).toBe(false); - })); + it('should validate groupName for invalid characters', () => { + formHelper.setValue('groupName', 'test@group'); + formHelper.expectError('groupName', 'invalidChars'); + }); }); describe('onSubmit', () => { @@ -158,7 +106,6 @@ describe('NvmeofGroupFormComponent', () => { } as any; component.groupForm.get('groupName').setValue('test-group'); - component.groupForm.get('pool').setValue('rbd'); component.onSubmit(); expect(cephServiceService.create).not.toHaveBeenCalled(); @@ -170,16 +117,14 @@ describe('NvmeofGroupFormComponent', () => { getSelectedHostnames: (): string[] => ['host1', 'host2'] } as any; - component.groupForm.get('groupName').setValue('defalut'); - component.groupForm.get('pool').setValue('rbd'); + component.groupForm.get('groupName').setValue('default'); component.groupForm.get('unmanaged').setValue(false); component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ service_type: 'nvmeof', - service_id: 'rbd.defalut', - pool: 'rbd', - group: 'defalut', + service_id: 'default', + group: 'default', placement: { hosts: ['host1', 'host2'] }, @@ -194,15 +139,32 @@ describe('NvmeofGroupFormComponent', () => { } as any; component.groupForm.get('groupName').setValue('unmanaged-group'); - component.groupForm.get('pool').setValue('rbd'); component.groupForm.get('unmanaged').setValue(true); component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith( jasmine.objectContaining({ unmanaged: true, - group: 'unmanaged-group', - pool: 'rbd' + group: 'unmanaged-group' + }) + ); + }); + + it('should create service with encryption when enabled', () => { + component.gatewayNodeComponent = { + getSelectedHosts: (): any[] => [{ hostname: 'host1' }], + getSelectedHostnames: (): string[] => ['host1'] + } as any; + + component.groupForm.get('groupName').setValue('encrypted-group'); + component.groupForm.get('enableEncryption').setValue(true); + component.groupForm.get('encryptionConfig').setValue('encryption-key-123'); + component.onSubmit(); + + expect(cephServiceService.create).toHaveBeenCalledWith( + jasmine.objectContaining({ + group: 'encrypted-group', + encryption_key: 'encryption-key-123' }) ); }); @@ -214,7 +176,6 @@ describe('NvmeofGroupFormComponent', () => { } as any; component.groupForm.get('groupName').setValue('test-group'); - component.groupForm.get('pool').setValue('rbd'); component.onSubmit(); expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts index 533025735bc..c1fb56b251e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts @@ -6,8 +6,6 @@ import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { PoolService } from '~/app/shared/api/pool.service'; -import { Pool } from '../../pool/pool'; import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; @@ -30,15 +28,12 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { action: string; resource: string; group: string; - pools: Pool[] = []; - poolsLoading = false; pageURL: string; hasAvailableNodes = true; constructor( private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, - private poolService: PoolService, private taskWrapperService: TaskWrapperService, private cephServiceService: CephServiceService, private nvmeofService: NvmeofService, @@ -52,7 +47,6 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { ngOnInit() { this.action = this.actionLabels.CREATE; this.createForm(); - this.loadPools(); } createForm() { @@ -68,9 +62,6 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { ], [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)] ), - pool: new UntypedFormControl('rbd', { - validators: [Validators.required] - }), unmanaged: new UntypedFormControl(false), enableEncryption: new UntypedFormControl(false), encryptionConfig: new UntypedFormControl(null) @@ -118,27 +109,6 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { return false; } - loadPools() { - this.poolsLoading = true; - this.poolService.list().then( - (pools: Pool[]) => { - this.pools = (pools || []).filter( - (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd') - ); - this.poolsLoading = false; - if (this.pools.length >= 1) { - const allPoolNames = this.pools.map((pool) => pool.pool_name); - const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name; - this.groupForm.patchValue({ pool: poolName }); - } - }, - () => { - this.pools = []; - this.poolsLoading = false; - } - ); - } - onSubmit() { if (this.groupForm.invalid) { return; @@ -156,12 +126,11 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit { return; } let taskUrl = `service/${URLVerbs.CREATE}`; - const serviceName = `${formValues.pool}.${formValues.groupName}`; + const serviceName = `${formValues.groupName}`; const serviceSpec: Record = { service_type: 'nvmeof', service_id: serviceName, - pool: formValues.pool, group: formValues.groupName, placement: { hosts: selectedHostnames diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html index c77c36abc14..0e7ecb82f24 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -111,45 +111,6 @@
} - - - @if (serviceForm.controls.service_type.value === 'nvmeof') { -
- - @if (rbdPools === null) { - - } - @if (rbdPools && rbdPools.length === 0) { - - } - @if (rbdPools && rbdPools.length > 0) { - - } - @for (pool of rbdPools; track pool) { - - } - - - - @if (serviceForm.showError('pool', frm, 'required')) { - This field is required. - } - -
- } - @if (serviceForm.controls.service_type.value === 'nvmeof') {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts index 915c320bacc..a1c2590e20f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -511,50 +511,29 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== it('should set default values correctly onInit', () => { expect(form.get('service_type').value).toBe('nvmeof'); expect(form.get('group').value).toBe('default'); - expect(form.get('pool').value).toBe('rbd'); - expect(form.get('service_id').value).toBe('rbd.default'); + expect(form.get('service_id').value).toBe('default'); }); it('should reflect correct values on group change', () => { - // Initially the group value should be 'default' expect(component.serviceForm.get('group')?.value).toBe('default'); const groupInput = fixture.debugElement.query(By.css('#group')).nativeElement; - // Simulate input value change groupInput.value = 'foo'; - // Trigger the input event groupInput.dispatchEvent(new Event('input')); - // Trigger the change event groupInput.dispatchEvent(new Event('change')); fixture.detectChanges(); - // Verify values after change expect(form.get('group').value).toBe('foo'); - expect(form.get('service_id').value).toBe('rbd.foo'); + expect(form.get('service_id').value).toBe('foo'); }); - it('should reflect correct values on pool change', () => { - // Initially the pool value should be 'rbd' - expect(component.serviceForm.get('pool')?.value).toBe('rbd'); - const poolInput = fixture.debugElement.query(By.css('#pool')).nativeElement; - // Simulate input value change - form.get('pool').setValue('pool-2'); - // Trigger the input event - poolInput.dispatchEvent(new Event('input')); - // Trigger the change event - poolInput.dispatchEvent(new Event('change')); - fixture.detectChanges(); - // Verify values after change - expect(form.get('pool').value).toBe('pool-2'); - expect(form.get('service_id').value).toBe('pool-2.default'); + it('should hide pool selector in create mode', () => { + const poolEl = fixture.debugElement.query(By.css('#pool')); + expect(poolEl).toBeNull(); }); it('should throw error when there is no service id', () => { formHelper.expectErrorChange('service_id', '', 'required'); }); - it('should throw error when there is no pool', () => { - formHelper.expectErrorChange('pool', '', 'required'); - }); - it('should throw error when there is no group', () => { formHelper.expectErrorChange('group', '', 'required'); }); @@ -583,10 +562,9 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ service_type: 'nvmeof', - service_id: 'rbd.default', + service_id: 'default', placement: {}, unmanaged: false, - pool: 'rbd', group: 'default', enable_auth: false }); @@ -602,10 +580,9 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== component.onSubmit(); expect(cephServiceService.create).toHaveBeenCalledWith({ service_type: 'nvmeof', - service_id: 'rbd.default', + service_id: 'default', placement: {}, unmanaged: false, - pool: 'rbd', group: 'default', enable_auth: true, root_ca_cert: 'root_ca_cert', @@ -840,15 +817,6 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== expect(serviceId.disabled).toBeTruthy(); }); - it('should not edit pools for nvmeof service', () => { - component.serviceType = 'nvmeof'; - formHelper.setValue('service_type', 'nvmeof'); - component.ngOnInit(); - fixture.detectChanges(); - const poolId = fixture.componentInstance.serviceForm.get('pool'); - expect(poolId.disabled).toBeTruthy(); - }); - it('should not edit groups for nvmeof service', () => { component.serviceType = 'nvmeof'; formHelper.setValue('service_type', 'nvmeof'); @@ -862,16 +830,13 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA== spyOn(cephServiceService, 'update').and.stub(); component.serviceType = 'nvmeof'; formHelper.setValue('service_type', 'nvmeof'); - formHelper.setValue('pool', 'rbd'); formHelper.setValue('group', 'default'); - // mTLS disabled formHelper.setValue('enable_mtls', false); component.onSubmit(); expect(cephServiceService.update).toHaveBeenCalledWith({ service_type: 'nvmeof', placement: {}, unmanaged: false, - pool: 'rbd', group: 'default', enable_auth: false }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index fb5f36af337..86a074fef8c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -222,9 +222,6 @@ export class ServiceFormComponent extends CdForm implements OnInit { [ CdValidators.requiredIf({ service_type: 'iscsi' - }), - CdValidators.requiredIf({ - service_type: 'nvmeof' }) ] ], @@ -782,7 +779,6 @@ export class ServiceFormComponent extends CdForm implements OnInit { } break; case 'nvmeof': - this.serviceForm.get('pool').setValue(response[0].spec.pool); this.serviceForm.get('group').setValue(response[0].spec.group); this.serviceForm.get('enable_mtls').setValue(response[0].spec?.enable_auth); this.serviceForm.get('root_ca_cert').setValue(response[0].spec?.root_ca_cert); @@ -1155,26 +1151,14 @@ export class ServiceFormComponent extends CdForm implements OnInit { } setNvmeServiceId() { - const pool = this.serviceForm.get('pool').value; const group = this.serviceForm.get('group').value; - if (pool && group) { - this.serviceForm.get('service_id').setValue(`${pool}.${group}`); - } else if (pool) { - this.serviceForm.get('service_id').setValue(pool); - } else if (group) { + if (group) { this.serviceForm.get('service_id').setValue(group); } else { this.serviceForm.get('service_id').setValue(null); } } - setNvmeDefaultPool() { - const defaultPool = - this.rbdPools?.find((p: Pool) => p.pool_name === 'rbd')?.pool_name || - this.rbdPools?.[0].pool_name; - this.serviceForm.get('pool').setValue(defaultPool); - } - requiresServiceId(serviceType: string) { return ['mds', 'rgw', 'nfs', 'iscsi', 'nvmeof', 'smb', 'ingress'].includes(serviceType); } @@ -1182,7 +1166,6 @@ export class ServiceFormComponent extends CdForm implements OnInit { setServiceId(serviceId: string): void { const requiresServiceId: boolean = this.requiresServiceId(serviceId); if (requiresServiceId && serviceId === 'nvmeof') { - this.setNvmeDefaultPool(); this.setNvmeServiceId(); } else if (requiresServiceId) { this.serviceForm.get('service_id').setValue(null); @@ -1231,7 +1214,6 @@ export class ServiceFormComponent extends CdForm implements OnInit { this.serviceForm.get('backend_service').disable(); break; case 'nvmeof': - this.serviceForm.get('pool').disable(); this.serviceForm.get('group').disable(); break; } @@ -1344,7 +1326,6 @@ export class ServiceFormComponent extends CdForm implements OnInit { break; case 'nvmeof': - serviceSpec['pool'] = values['pool']; serviceSpec['group'] = values['group']; serviceSpec['enable_auth'] = values['enable_mtls']; if (values['enable_mtls']) { From 13e7fd758a5d505cadfb65ba2d9a8c9c30524b40 Mon Sep 17 00:00:00 2001 From: Max Kellermann Date: Thu, 25 Jun 2026 21:34:27 +0200 Subject: [PATCH 443/596] rgw: add missing include Signed-off-by: Max Kellermann --- src/rgw/rgw_cors.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/rgw/rgw_cors.h b/src/rgw/rgw_cors.h index 9b4fd0ee7ec..c39f3cbb577 100644 --- a/src/rgw/rgw_cors.h +++ b/src/rgw/rgw_cors.h @@ -18,6 +18,7 @@ #include #include #include +#include "include/encoding.h" // for WRITE_CLASS_ENCODER() #include "include/str_list.h" #define RGW_CORS_GET 0x1 @@ -35,6 +36,8 @@ #define CORS_MAX_AGE_INVALID ((uint32_t)-1) +namespace ceph { class Formatter; } + class RGWCORSRule { protected: @@ -90,7 +93,7 @@ public: void format_exp_headers(std::string& s); void erase_origin_if_present(std::string& origin, bool *rule_empty); void dump_origins(); - void dump(Formatter *f) const; + void dump(ceph::Formatter *f) const; bool is_header_allowed(const char *hdr, size_t len); bool matches_method(const char *req_meth); bool matches_preflight_headers(const char *req_hdrs); @@ -118,7 +121,7 @@ class RGWCORSConfiguration decode(rules, bl); DECODE_FINISH(bl); } - void dump(Formatter *f) const; + void dump(ceph::Formatter *f) const; std::list& get_rules() { return rules; } From c82a83fc96036bacccebff19834c3b584a870e2e Mon Sep 17 00:00:00 2001 From: Ilya Dryomov Date: Fri, 26 Jun 2026 12:56:27 +0200 Subject: [PATCH 444/596] script/ptl-tool: fix redmine_linkage_correct case in _get_active_issues() If redmine_linkage_correct is True, _get_active_issues() is supposed to ignore issues with "Multiple Source PRs" category. However that doesn't happen because the category string is compared against (category, text) pair. Signed-off-by: Ilya Dryomov --- src/script/ptl-tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/ptl-tool.py b/src/script/ptl-tool.py index 762069ca3b5..c7ac3d928e8 100755 --- a/src/script/ptl-tool.py +++ b/src/script/ptl-tool.py @@ -207,7 +207,7 @@ class AuditReport: def _get_active_issues(self): if self.redmine_linkage_correct: - return [i for i in self.issues if i != "Multiple Source PRs"] + return [(cat, text) for cat, text in self.issues if cat != "Multiple Source PRs"] return self.issues def has_errors(self) -> bool: From d890779f7523cff9af4c740ea2a5ddb83910d993 Mon Sep 17 00:00:00 2001 From: pujashahu Date: Thu, 11 Jun 2026 15:59:51 +0530 Subject: [PATCH 445/596] mgr/dashboard: Add auto-fetch listeners option in subsystem page Fixes: https://tracker.ceph.com/issues/77083 Signed-off-by: pujaoshahu --- .../nvmeof-subsystem-step-1.component.html | 54 +++++++- .../nvmeof-subsystem-step-1.component.spec.ts | 9 +- .../nvmeof-subsystem-step-1.component.ts | 24 +++- .../nvmeof-subsystems-form.component.spec.ts | 94 ++++++++++++++ .../nvmeof-subsystems-form.component.ts | 115 +++++++++++------- .../src/app/shared/api/nvmeof.service.ts | 7 +- .../frontend/src/app/shared/models/nvmeof.ts | 2 + 7 files changed, 251 insertions(+), 54 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html index a811c593088..aab200d7a1c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html @@ -53,10 +53,59 @@
} +
+
+ Listeners +

Determine where and how hosts can connect to the subsystem over the network.

+ + + Auto-fetch + + + Add manually + + +
+
+ @if (listenerMode === LISTENER_MODE.AUTO_FETCH) { +
+ + Subnet-mask + + + + This field is required. + +
+ } + @if (listenerMode === LISTENER_MODE.MANUAL) {
} - Listeners + Select listeners Select listeners for this subsystem. @@ -88,6 +137,7 @@ This field is required.
+ }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts index 70db8540580..237a76cb3d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts @@ -2,6 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; @@ -10,7 +11,7 @@ import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component'; import { FormHelper } from '~/testing/unit-test-helper'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular'; +import { ComboBoxModule, GridModule, InputModule, RadioModule } from 'carbon-components-angular'; import { of } from 'rxjs'; @@ -35,9 +36,11 @@ describe('NvmeofSubsystemsStepOneComponent', () => { NgbTypeaheadModule, InputModule, GridModule, - ComboBoxModule + ComboBoxModule, + RadioModule ], - providers: [NgbActiveModal] + providers: [NgbActiveModal], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts index 866717ef4a5..8894178b9d6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts @@ -33,6 +33,11 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { }; hosts: ListenerItem[] = []; + LISTENER_MODE = { + AUTO_FETCH: 'auto-fetch', + MANUAL: 'manual' + }; + listenerMode: string = this.LISTENER_MODE.AUTO_FETCH; constructor( public actionLabels: ActionLabelsI18n, @@ -76,9 +81,18 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { } createForm() { + const subnetMaskValidators = [ + CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.AUTO_FETCH }, [Validators.required]) + ]; + const listenersValidators = [ + CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.MANUAL }, [Validators.required]) + ]; + if (this.listenersOnly) { this.formGroup = new CdFormGroup({ - listeners: new UntypedFormControl([]) + listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH), + subnetMask: new UntypedFormControl('', subnetMaskValidators), + listeners: new UntypedFormControl([], listenersValidators) }); } else { this.formGroup = new CdFormGroup({ @@ -101,9 +115,15 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { ) ] }), - listeners: new UntypedFormControl([]) + listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH), + subnetMask: new UntypedFormControl('', subnetMaskValidators), + listeners: new UntypedFormControl([], listenersValidators) }); } + + this.formGroup.get('listenerMode').valueChanges.subscribe((mode: string) => { + this.listenerMode = mode; + }); } removeListener(index: number) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts index dc98d870f4d..4f65eaa1f3e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts @@ -92,6 +92,7 @@ describe('NvmeofSubsystemsFormComponent', () => { nvmeofService = TestBed.inject(NvmeofService); spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({})); spyOn(nvmeofService, 'addSubsystemInitiators').and.returnValue(of({})); + spyOn(nvmeofService, 'createListeners').and.returnValue(of({})); }); it('should be creating request correctly', () => { @@ -105,6 +106,99 @@ describe('NvmeofSubsystemsFormComponent', () => { }); }); + it('should include network_mask in createSubsystem request when listenerMode is auto-fetch and subnetMask is set', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'auto-fetch', + subnetMask: '192.168.1.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({ + nqn: 'test-nqn', + gw_group: mockGroupName, + dhchap_key: '', + network_mask: ['192.168.1.0/24'] + }); + }); + + it('should not include network_mask in createSubsystem request when listenerMode is manual', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'manual', + subnetMask: '192.168.1.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({ + nqn: 'test-nqn', + gw_group: mockGroupName, + dhchap_key: '' + }); + }); + + it('should call createListeners when listenerMode is manual and listeners are provided', () => { + const listeners = [{ content: 'host1', addr: '10.0.0.1' }]; + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners, + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'manual' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createListeners).toHaveBeenCalledWith( + 'test-nqn.default', + mockGroupName, + listeners + ); + }); + + it('should not call createListeners when listenerMode is auto-fetch', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: '', + listeners: [{ content: 'host1', addr: '10.0.0.1' }], + authType: AUTHENTICATION.Unidirectional, + hostDchapKeyList: [], + listenerMode: 'auto-fetch', + subnetMask: '10.0.0.0/24' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.createListeners).not.toHaveBeenCalled(); + }); + it('should add initiators with wildcard when hostType is ALL', () => { const payload: SubsystemPayload = { nqn: 'test-nqn', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts index b3d46425568..88178fb0c8c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts @@ -30,10 +30,26 @@ export type SubsystemPayload = { listeners: ListenerItem[]; authType: AUTHENTICATION.Bidirectional | AUTHENTICATION.Unidirectional; hostDchapKeyList: Array<{ dhchap_key: string; host_nqn: string }>; + listenerMode?: string; + subnetMask?: string; }; type StepResult = { step: string; success: boolean; error?: string }; +type CreateSubsystemRequest = { + nqn: string; + gw_group: string; + dhchap_key: string; + network_mask?: string[]; +}; + +type SequentialStep = { step: string; call: () => Observable }; + +const LISTENER_MODE = { + AUTO_FETCH: 'auto-fetch', + MANUAL: 'manual' +}; + const STEP_LABELS = { DETAILS: 'Subsystem details', HOSTS: 'Host access control', @@ -159,62 +175,69 @@ export class NvmeofSubsystemsFormComponent implements OnInit { hosts: payload.hostType === HOST_TYPE.SPECIFIC ? payload.hostDchapKeyList : [], gw_group: this.group }; - this.nvmeofService - .createSubsystem({ - nqn: payload.nqn, - gw_group: this.group, - dhchap_key: payload.subsystemDchapKey - }) - .subscribe({ - next: () => { - stepResults.push({ step: this.steps[0].label, success: true }); - const sequentialSteps: { step: string; call: () => Observable }[] = []; - if (payload.listeners && payload.listeners.length > 0) { - sequentialSteps.push({ - step: $localize`Listeners`, - call: () => - this.nvmeofService.createListeners( - `${payload.nqn}.${this.group}`, - this.group, - payload.listeners - ) - }); - } + // Prepare subsystem creation request + const createSubsystemRequest: CreateSubsystemRequest = { + nqn: payload.nqn, + gw_group: this.group, + dhchap_key: payload.subsystemDchapKey + }; + if (payload.listenerMode === LISTENER_MODE.AUTO_FETCH && payload.subnetMask) { + createSubsystemRequest.network_mask = [payload.subnetMask]; + } + + this.nvmeofService.createSubsystem(createSubsystemRequest).subscribe({ + next: () => { + stepResults.push({ step: this.steps[0].label, success: true }); + const sequentialSteps: SequentialStep[] = []; + + if ( + payload.listenerMode !== LISTENER_MODE.AUTO_FETCH && + payload.listeners && + payload.listeners.length > 0 + ) { sequentialSteps.push({ - step: this.steps[1].label, + step: $localize`Listeners`, call: () => - this.nvmeofService.addSubsystemInitiators( + this.nvmeofService.createListeners( `${payload.nqn}.${this.group}`, - initiatorRequest + this.group, + payload.listeners ) }); - - this.runSequentialSteps(sequentialSteps, stepResults).subscribe({ - complete: () => this.showFinalNotification(stepResults) - }); - }, - error: (err) => { - err.preventDefault(); - const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`; - this.notificationService.show( - NotificationType.error, - $localize`Subsystem creation failed`, - errorMsg - ); - this.isSubmitLoading = false; - this.router.navigate(['block/nvmeof/subsystems'], { - queryParams: { group: this.group } - }); } - }); + + sequentialSteps.push({ + step: this.steps[1].label, + call: () => + this.nvmeofService.addSubsystemInitiators( + `${payload.nqn}.${this.group}`, + initiatorRequest + ) + }); + + this.runSequentialSteps(sequentialSteps, stepResults).subscribe({ + complete: () => this.showFinalNotification(stepResults) + }); + }, + error: (err) => { + err.preventDefault(); + const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`; + this.notificationService.show( + NotificationType.error, + $localize`Subsystem creation failed`, + errorMsg + ); + this.isSubmitLoading = false; + this.router.navigate(['block/nvmeof/subsystems'], { + queryParams: { group: this.group } + }); + } + }); } - private runSequentialSteps( - steps: { step: string; call: () => Observable }[], - stepResults: StepResult[] - ): Observable { + private runSequentialSteps(steps: SequentialStep[], stepResults: StepResult[]): Observable { return from(steps).pipe( concatMap((step) => step.call().pipe( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index 3b2b57dbf35..77c31a6553d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -192,7 +192,12 @@ export class NvmeofService { return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`); } - createSubsystem(request: { nqn: string; gw_group: string; dhchap_key: string }) { + createSubsystem(request: { + nqn: string; + gw_group: string; + dhchap_key: string; + network_mask?: string[]; + }) { return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index 11d7edab3b0..7d401ad5d08 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -169,4 +169,6 @@ export type AuthStepType = { export type DetailsStepType = { nqn: string; listeners: Array; + listenerMode?: string; + subnetMask?: string; }; From d83f5dcdb905bd7b1de0ffaba951c7b6d491f467 Mon Sep 17 00:00:00 2001 From: pujashahu Date: Wed, 17 Jun 2026 18:37:24 +0530 Subject: [PATCH 446/596] mgr/dashboard: "Any" initiator entry appears in the Initiator table after selecting "All Hosts Allowed" during subsystem creation Fixes: https://tracker.ceph.com/issues/77467 Signed-off-by: pujaoshahu --- .../src/app/ceph/block/block.module.ts | 2 +- .../nvmeof-gateway-node.component.ts | 2 +- .../nvmeof-gateway-subsystem.component.ts | 2 +- .../nvmeof-initiators-list.component.html | 47 ++++++++++--------- .../nvmeof-initiators-list.component.spec.ts | 5 +- .../nvmeof-initiators-list.component.ts | 22 ++++++--- .../nvmeof-subsystems.component.ts | 2 +- .../src/app/shared/enum/nvmeof.enum.ts | 10 ---- .../frontend/src/app/shared/models/nvmeof.ts | 12 +++++ 9 files changed, 62 insertions(+), 42 deletions(-) delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index b0852ec9b6b..cadf130be26 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -97,7 +97,7 @@ import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvme import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component'; import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component'; import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver'; -import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; +import { NvmeofGatewayNodeMode } from '~/app/shared/models/nvmeof'; import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component'; import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component'; import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview/nvmeof-subsystem-overview.component'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts index 32549d1a828..2de89e3a478 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts @@ -17,7 +17,6 @@ import _ from 'lodash'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { HostStatus } from '~/app/shared/enum/host-status.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; -import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; @@ -26,6 +25,7 @@ import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface'; import { Permission } from '~/app/shared/models/permissions'; import { Host } from '~/app/shared/models/host.interface'; +import { NvmeofGatewayNodeMode } from '~/app/shared/models/nvmeof'; import { CephServiceSpec, CephServiceSpecUpdate } from '~/app/shared/models/service.interface'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { CephServiceService } from '~/app/shared/api/ceph-service.service'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts index ef6575e47ea..de6732de69e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts @@ -7,13 +7,13 @@ import { NvmeofSubsystem, NvmeofSubsystemData, NvmeofSubsystemInitiator, + NvmeofSubsystemAuthType, getSubsystemAuthStatus } from '~/app/shared/models/nvmeof'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { ICON_TYPE, EMPTY_STATE_IMAGE } from '~/app/shared/enum/icons.enum'; -import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum'; @Component({ selector: 'cd-nvmeof-gateway-subsystem', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html index 97a520db0c6..8c924ce0152 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html @@ -1,27 +1,21 @@ - -
+@if (hasAllHostsAllowed()) { +
- Host access: All hosts allowed -

+ gap="1"> + All hosts allowed +

Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.

- Edit host access -
-
+ +} -
- + + + {{ getDisplayedHostNqn(row.nqn) }} + + +
- {{ authStatus !== authType.NO_AUTH ? 'Yes' : 'No' }} + @if (row.nqn === allowAllHost) { + {{ '-' }} + } @else { + {{ authStatus !== authType.NO_AUTH ? yesLabel : noLabel }} + }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts index 2b42b8c339d..05d956256cb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts @@ -9,12 +9,13 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalService } from '~/app/shared/services/modal.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { ALLOW_ALL_HOST } from '~/app/shared/models/nvmeof'; import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.component'; const mockInitiators = [ { - nqn: '*', + nqn: ALLOW_ALL_HOST, use_dhchap: false } ]; @@ -79,6 +80,8 @@ describe('NvmeofInitiatorsListComponent', () => { expect(component.initiators).toEqual(mockInitiators); expect(component.subsystem).toEqual(mockSubsystem); expect(component.authStatus).toBe('No authentication'); + expect(component.initiatorColumns.length).toBe(2); + expect(component.getDisplayedHostNqn(ALLOW_ALL_HOST)).toBe('Any'); })); it('should update authStatus when initiator has dhchap_key', fakeAsync(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts index e4f4bb14fb2..4aa5fccb460 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts @@ -12,10 +12,11 @@ import { FinishedTask } from '~/app/shared/models/finished-task'; import { NvmeofSubsystem, NvmeofSubsystemInitiator, + ALLOW_ALL_HOST, + NvmeofSubsystemAuthType, getSubsystemAuthStatus } from '~/app/shared/models/nvmeof'; import { Permission } from '~/app/shared/models/permissions'; -import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -35,6 +36,8 @@ export class NvmeofInitiatorsListComponent implements OnInit { @ViewChild('dhchapTpl', { static: true }) dhchapTpl: TemplateRef; + @ViewChild('hostNqnTpl', { static: true }) + hostNqnTpl: TemplateRef; initiatorColumns: CdTableColumn[]; tableActions: CdTableAction[]; @@ -44,6 +47,9 @@ export class NvmeofInitiatorsListComponent implements OnInit { subsystem: NvmeofSubsystem; authStatus: string; authType = NvmeofSubsystemAuthType; + allowAllHost = ALLOW_ALL_HOST; + yesLabel = $localize`Yes`; + noLabel = $localize`No`; constructor( public actionLabels: ActionLabelsI18n, @@ -78,7 +84,8 @@ export class NvmeofInitiatorsListComponent implements OnInit { this.initiatorColumns = [ { name: $localize`Host NQN`, - prop: 'nqn' + prop: 'nqn', + cellTemplate: this.hostNqnTpl }, { name: $localize`DHCHAP key`, @@ -96,8 +103,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { queryParams: { group: this.group }, relativeTo: this.route.parent }), - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, - disable: () => this.hasAllHostsAllowed() + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection }, { name: $localize`Edit host key`, @@ -137,11 +143,11 @@ export class NvmeofInitiatorsListComponent implements OnInit { } getAllowAllHostIndex() { - return this.selection.selected.findIndex((selected) => selected.nqn === '*'); + return this.selection.selected.findIndex((selected) => selected.nqn === ALLOW_ALL_HOST); } hasAllHostsAllowed(): boolean { - return this.initiators.some((initiator) => initiator.nqn === '*'); + return this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST); } updateSelection(selection: CdTableSelection) { @@ -175,6 +181,10 @@ export class NvmeofInitiatorsListComponent implements OnInit { return this.selection.selected.map((selected) => selected.nqn); } + getDisplayedHostNqn(hostNqn: string): string { + return hostNqn === ALLOW_ALL_HOST ? $localize`Any` : hostNqn; + } + removeInitiatorModal() { const hostNQNs = this.getSelectedNQNs(); const allowAllHostIndex = this.getAllowAllHostIndex(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts index 71c954d34ae..85590b314b5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -5,6 +5,7 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { NvmeofSubsystem, NvmeofSubsystemInitiator, + NvmeofSubsystemAuthType, getSubsystemAuthStatus } from '~/app/shared/models/nvmeof'; import { Permissions } from '~/app/shared/models/permissions'; @@ -14,7 +15,6 @@ import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { Icons } from '~/app/shared/enum/icons.enum'; -import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts deleted file mode 100644 index 816613dc595..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum NvmeofSubsystemAuthType { - NO_AUTH = 'No authentication', - UNIDIRECTIONAL = 'Unidirectional', - BIDIRECTIONAL = 'Bi-directional' -} - -export enum NvmeofGatewayNodeMode { - SELECTOR = 'selector', - DETAILS = 'details' -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index 11d7edab3b0..ccc43e72499 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -72,12 +72,24 @@ export interface NvmeofGatewayGroup extends CephServiceSpec { nodeCount: number; } +export enum NvmeofSubsystemAuthType { + NO_AUTH = 'No authentication', + UNIDIRECTIONAL = 'Unidirectional', + BIDIRECTIONAL = 'Bi-directional' +} + +export enum NvmeofGatewayNodeMode { + SELECTOR = 'selector', + DETAILS = 'details' +} + export enum AUTHENTICATION { Unidirectional = 'unidirectional', Bidirectional = 'bidirectional', None = 'none' } +export const ALLOW_ALL_HOST = '*'; export const NO_AUTH = 'No authentication'; export const HOST_TYPE = { From e59ab346663ca7cf9c81f064e6f91c0dcb2b370e Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sat, 27 Jun 2026 07:00:33 +0800 Subject: [PATCH 447/596] qa/suites/rados/mgr: ignorelist MGR_INFLUX_DB_LIST_FAILED in module_selftest module_selftest fails on a cluster log warning that is not ignorelisted: cluster [WRN] Health check failed: Failed to list/create InfluxDB database (MGR_INFLUX_DB_LIST_FAILED) module_selftest is the only job that enables the influx module, and no suite provisions an InfluxDB server, so once enabled the module cannot reach a backend and raises a health warning: MGR_INFLUX_NO_SERVER when no hostname is set, MGR_INFLUX_DB_LIST_FAILED from serve() once one is. test_influx sets a hostname, so it hits the latter. This surfaced only after the test image started shipping the influxdb python module. Until then can_run() returned false, the module never enabled, and the sole log line was "influxdb python module not found", which is already ignorelisted. self_test() cannot run without enabling the module, so ignorelist the warning it raises. Signed-off-by: Kefu Chai --- qa/suites/rados/mgr/tasks/4-units/module_selftest.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/qa/suites/rados/mgr/tasks/4-units/module_selftest.yaml b/qa/suites/rados/mgr/tasks/4-units/module_selftest.yaml index e2a2ca03cc9..a4075512944 100644 --- a/qa/suites/rados/mgr/tasks/4-units/module_selftest.yaml +++ b/qa/suites/rados/mgr/tasks/4-units/module_selftest.yaml @@ -6,6 +6,7 @@ overrides: - objects misplaced - Synthetic exception in serve - influxdb python module not found + - Failed to list/create InfluxDB database \(MGR_INFLUX_DB_LIST_FAILED\) - foo bar - Failed to open Telegraf - evicting unresponsive client From a62a982aac660dfa151b5ad33e30592db5800915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Sun, 10 May 2026 03:04:13 +0000 Subject: [PATCH 448/596] docs: balancer: Elaborate what "average" means. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wording "average" so far suggested a simple mean count, which it isn't. It is important to document this, so that the user can follow why the balancer is active or not. Source for the logic (deviation computation and threshold check) in `src/osd/OSDMap.cc`: `calc_deviations()` - computes per-OSD deviation: float target = osd_weight.at(oid) * pgs_per_weight; float deviation = (float)opgs.size() - target; Where `pgs_per_weight` is computed in `calc_pg_upmaps()`: float pgs_per_weight = total_pgs / osd_weight_total; This also has the early threshold + early return: if (cur_max_deviation <= max_deviation) { ldout(cct, 10) << __func__ << " distribution is almost perfect" << dendl; return 0; } `fill_overfull_underfull()` classifies OSDs using `max_deviation`: if (odev > max_deviation) { overfull.insert(oid); // deviation > +5 -> source candidate } if (odev < -(int)max_deviation) { underfull.push_back(oid); // deviation < -5 -> target candidate } Signed-off-by: Niklas Hambüchen --- doc/rados/operations/balancer.rst | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/doc/rados/operations/balancer.rst b/doc/rados/operations/balancer.rst index 9d92a81e2a2..a797f9be205 100644 --- a/doc/rados/operations/balancer.rst +++ b/doc/rados/operations/balancer.rst @@ -63,9 +63,22 @@ at the potential cost of greater impact on client operations. There is a separate setting ``upmap_max_deviation`` for how uniform the distribution of PGs must be for the module to consider the cluster adequately balanced. At the time of writing (June 2025), this value defaults to ``5``, -which means that if a given OSD's PG replicas vary by five or fewer above or -below the cluster's average, it will be considered sufficiently balanced. +which means that if a given OSD's PG shard count deviates by five or fewer +from its weight-proportional target, it will be considered sufficiently +balanced. +More precisely, the balancer computes a per-OSD target shard count as:: + + target = osd_weight * (total_shards / total_weight) + +where ``osd_weight`` is the OSD's CRUSH weight times its reweight +(the ``REWEIGHT`` value from ``ceph osd df``), ``total_shards`` is +``pool_size * pg_num`` summed over all balanced pools, +and ``total_weight`` is the sum of those per-OSD weights. +The deviation is then ``actual_shard_count - target``. +If no OSD's absolute deviation exceeds +``upmap_max_deviation``, the balancer considers the distribution +sufficiently balanced and makes no changes. This value of PG replicas/shards (as distinct from logical PGs) is reported by the ``ceph osd df`` command under the ``PGS`` column and the variance From 8f83ec546aa16c3e06f1a2b1cbf4c8f657771f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Sun, 10 May 2026 03:35:52 +0000 Subject: [PATCH 449/596] docs: balancer: Explain limitations of PG shard count balancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Niklas Hambüchen --- doc/rados/operations/balancer.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/rados/operations/balancer.rst b/doc/rados/operations/balancer.rst index a797f9be205..bfd37a10aa4 100644 --- a/doc/rados/operations/balancer.rst +++ b/doc/rados/operations/balancer.rst @@ -39,6 +39,22 @@ combines the upmap balancer with the read balancer so that both writes and reads are optimized. ``read`` mode can be used when only read optimization is desired. For more details, see :ref:`read_balancer`. +Limitation: count-based balancing vs. size-based balancing +---------------------------------------------------------- + +Ceph's built-in balancer optimizes only by **PG shard count**, not by the +actual size of the data stored in each PG. + +This can result in clusters whose OSDs are balanced by PG shard count, +but very imbalanced by stored Bytes. +At the pool level, this can cause a pool's ``%USED`` (from ``ceph df``) +to be much higher than the cluster's ``%RAW USED``, because the +pool's fullest OSD (which determines the pool's available space) may be +disproportionately loaded with large PGs. + +Size-aware community balancers exist (for example, +`jj-balancer `_). + Throttling ---------- From 8e66072ec58666b9b504fb93b9ebd9daa42de6ab Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Sun, 21 Jun 2026 11:48:01 +0800 Subject: [PATCH 450/596] crimson/osd/pg_backend: mark the omaps of the object dirty when removing omap keys Fixes: https://tracker.ceph.com/issues/77548 Signed-off-by: Xuehan Xu --- src/crimson/osd/ops_executer.cc | 4 ++-- src/crimson/osd/pg_backend.cc | 9 +++++---- src/crimson/osd/pg_backend.h | 4 +++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/crimson/osd/ops_executer.cc b/src/crimson/osd/ops_executer.cc index 343b93b2dbc..5be20cc90e5 100644 --- a/src/crimson/osd/ops_executer.cc +++ b/src/crimson/osd/ops_executer.cc @@ -806,8 +806,8 @@ OpsExecuter::do_execute_op(OSDOp& osd_op) if (!pg.get_pgpool().info.supports_omap()) { return crimson::ct_error::operation_not_supported::make(); }*/ - return do_write_op([&osd_op](auto& backend, auto& os, auto& txn) { - return backend.omap_remove_key(os, osd_op, txn); + return do_write_op([&osd_op, this](auto& backend, auto& os, auto& txn) { + return backend.omap_remove_key(os, osd_op, txn, *osd_op_params, delta_stats); }); case CEPH_OSD_OP_OMAPCLEAR: return do_write_op([this, &osd_op](auto& backend, auto& os, auto& txn) { diff --git a/src/crimson/osd/pg_backend.cc b/src/crimson/osd/pg_backend.cc index 0c31e28f333..77b8dffdf01 100644 --- a/src/crimson/osd/pg_backend.cc +++ b/src/crimson/osd/pg_backend.cc @@ -1756,7 +1756,9 @@ PGBackend::interruptible_future<> PGBackend::omap_remove_range( PGBackend::interruptible_future<> PGBackend::omap_remove_key( ObjectState& os, const OSDOp& osd_op, - ceph::os::Transaction& txn) + ceph::os::Transaction& txn, + osd_op_params_t &osd_op_params, + object_stat_sum_t &delta_stats) { ceph::bufferlist to_rm_bl; try { @@ -1766,9 +1768,8 @@ PGBackend::interruptible_future<> PGBackend::omap_remove_key( throw crimson::osd::invalid_argument{}; } txn.omap_rmkeys(coll->get_cid(), ghobject_t{os.oi.soid}, to_rm_bl); - // TODO: - // ctx->clean_regions.mark_omap_dirty(); - // ctx->delta_stats.num_wr++; + osd_op_params.clean_regions.mark_omap_dirty(); + delta_stats.num_wr++; os.oi.clear_omap_digest(); return seastar::now(); } diff --git a/src/crimson/osd/pg_backend.h b/src/crimson/osd/pg_backend.h index ceecc354dce..e612a611c2d 100644 --- a/src/crimson/osd/pg_backend.h +++ b/src/crimson/osd/pg_backend.h @@ -404,7 +404,9 @@ public: interruptible_future<> omap_remove_key( ObjectState& os, const OSDOp& osd_op, - ceph::os::Transaction& trans); + ceph::os::Transaction& trans, + osd_op_params_t &osd_op_params, + object_stat_sum_t &delta_stats); using omap_clear_ertr = crimson::errorator; using omap_clear_iertr = ::crimson::interruptible::interruptible_errorator< From 1966b0c91755ab018dd9b43bfbdf72f9405f4761 Mon Sep 17 00:00:00 2001 From: Yael Azulay Date: Thu, 21 May 2026 14:38:28 +0300 Subject: [PATCH 451/596] cephadm: keep mgr ports in sync when modules are enabled/disabled ceph orch ps - was showing stale or incorrect port information for `mgr` daemons after enabling or disabling mgr modules (e.g., dashboard, prometheus). This fix ensures the displayed ports always reflect the actual active module endpoints. Code Fixes: ----------- Changes in src/pybind/mgr/cephadm/services/cephadmservice.py: - Extract and refactor _get_mgr_service_ports: The logic for reading active mgr module ports was inline inside prepare_create, using check_mon_command({'prefix': 'mgr services'}). It was extracted into a static helper method and refactored to read directly from mgr.get('mgr_map')['services'] instead -- which is faster and consistent with how get_dependencies reads the same data. The method is now shared by both prepare_create (existing use) and get_dependencies (new use). - Extract `_get_mgr_service_ports`: The logic for reading active mgr module ports from `mgr_map['services']` was inline inside `prepare_create`. It was extracted into a static helper method so it can be reused by both `prepare_create` (existing use) and `get_dependencies` (new use). Returns the port numbers currently registered by active mgr modules (e.g., `[8443, 9283]`). - Override `get_dependencies` in `MgrService`: The base class returns `[]` for all Ceph daemons. The serve loop compares `get_dependencies()` output against `last_deps` (saved after the previous reconfig). If they differ, a reconfig is triggered. By overriding this method in `MgrService`, we make the serve loop aware of mgr port changes. Returns a sorted list such as `['port:8443', 'port:9283', 'sd_port:8765']`. - Override `generate_config` in `MgrService`: After a reconfig completes, the result of `generate_config` is saved as `last_deps`. The inherited implementation returns `[]` as deps. If `[]` is saved but `get_dependencies` returns `['port:8443', 'sd_port:8765']`, they would always differ, causing an infinite reconfig loop. This override ensures that what is saved as `last_deps` matches what `get_dependencies` will return in the next iteration. Changes in `src/pybind/mgr/cephadm/serve.py`: - Update mgr cache on reconfig: The existing logic skipped the cache update for all Ceph daemon reconfigs. A new `elif` branch for mgr reconfigs fetches the existing cached `DaemonDescription`, updates only its `ports` field, and saves it back. This makes `ceph orch ps` reflect the correct ports immediately after a reconfig, without showing a misleading "starting" status. Changes in `src/cephadm/cephadm.py`: - Update `unit.meta` on reconfig: `unit.meta` is a per-daemon file on the host that stores metadata including ports. It is read periodically by `cephadm ls` and fed back into the orchestrator cache. Previously it was only written on initial deployment, so every periodic cache refresh would overwrite the corrected ports with stale data from `unit.meta`. This change updates the `ports` field in `unit.meta` during reconfig, ensuring the cache refresh confirms the correct state rather than reverting it. Changes to src/pybind/mgr/cephadm/tests/services/test_mgr.py: - Updated _prepare helper: The original mocked check_mon_command to feed services data to prepare_create. Since our _get_mgr_service_ports now reads from mgr.get('mgr_map') instead of check_mon_command, the mock was replaced with mock_store_set('_ceph_get', 'mgr_map', ...) which sets the data in the right place. - Added test_get_dependencies_changes_when_module_enabled: A new test that verifies get_dependencies returns different dep strings before and after a module is enabled. This directly tests the core sync mechanism -- the serve loop compares get_dependencies output against last_deps to detect port changes and trigger a reconfig. to run new tests: cd src/pybind/mgr tox -e py3 -- cephadm/tests/services/test_mgr.py -v Fixes: https://tracker.ceph.com/issues/76565 Signed-off-by: Yael Azulay --- src/cephadm/cephadm.py | 13 +++++++ src/pybind/mgr/cephadm/serve.py | 8 +++++ .../mgr/cephadm/services/cephadmservice.py | 36 +++++++++++++------ .../mgr/cephadm/tests/services/test_mgr.py | 33 ++++++++++++++--- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index c822efa3f6c..f85dc929711 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -1189,6 +1189,19 @@ def deploy_daemon( ) else: raise RuntimeError('attempting to deploy a daemon without a container image') + else: + # On reconfig, update unit.meta so that port metadata + # stays current without requiring a full redeploy. + meta_path = os.path.join(data_dir, 'unit.meta') + ports = [e.port for e in endpoints] if endpoints else [] + try: + update_meta_file(meta_path, {'ports': ports}) + except FileNotFoundError: + logger.warning(f'unit.meta not found at {meta_path}, skipping port update') + except Exception as e: + logger.warning( + f'failed to update unit.meta at {meta_path}, skipping port update: {e}' + ) if not os.path.exists(data_dir + '/unit.created'): with write_new(data_dir + '/unit.created', owner=(uid, gid)) as f: diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 8f1af9b18b0..4b4b60a09b8 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1543,6 +1543,14 @@ class CephadmServe: sd.update_pending_daemon_config(True) self.mgr.cache.add_daemon(daemon_spec.host, sd) self.mgr.cache.invalidate_host_daemons(daemon_spec.host) + elif reconfig and daemon_spec.daemon_type == 'mgr': + cache_host = daemon_spec.host + if not code and self.mgr.cache.has_daemon(daemon_spec.name()): + existing_dd = self.mgr.cache.get_daemon(daemon_spec.name()) + existing_dd.ports = daemon_spec.ports + cache_host = existing_dd.hostname or daemon_spec.host + self.mgr.cache.add_daemon(cache_host, existing_dd) + self.mgr.cache.invalidate_host_daemons(cache_host) if daemon_spec.daemon_type != 'agent': self.mgr.cache.update_daemon_config_deps( diff --git a/src/pybind/mgr/cephadm/services/cephadmservice.py b/src/pybind/mgr/cephadm/services/cephadmservice.py index 649474c3be5..2a3591f9834 100644 --- a/src/pybind/mgr/cephadm/services/cephadmservice.py +++ b/src/pybind/mgr/cephadm/services/cephadmservice.py @@ -1231,6 +1231,30 @@ class MgrService(CephService): # are not a concern. return True + @staticmethod + def _get_mgr_service_ports(mgr: "CephadmOrchestrator") -> List[int]: + """Return the list of ports from the mgr map services.""" + ports: List[int] = [] + mgr_map = mgr.get('mgr_map') + for end_point in mgr_map.get('services', {}).values(): + port = re.search(r'\:(\d+)\/', end_point) + if port: + ports.append(int(port.group(1))) + return ports + + @classmethod + def get_dependencies(cls, mgr: "CephadmOrchestrator", + spec: Optional[ServiceSpec] = None, + daemon_type: Optional[str] = None) -> List[str]: + return sorted( + [f'port:{p}' for p in cls._get_mgr_service_ports(mgr)] + + [f'sd_port:{mgr.service_discovery_port}'] + ) + + def generate_config(self, daemon_spec: CephadmDaemonDeploySpec) -> Tuple[Dict[str, Any], List[str]]: + config, _ = super().generate_config(daemon_spec) + return config, self.get_dependencies(self.mgr) + def prepare_create(self, daemon_spec: CephadmDaemonDeploySpec) -> CephadmDaemonDeploySpec: """ Create a new manager instance on a host. @@ -1250,17 +1274,7 @@ class MgrService(CephService): # user has decided to use different dashboard ports in each server # If this is the case then the dashboard port opened will be only the used # as default. - ports = [] - ret, mgr_services, err = self.mgr.check_mon_command({ - 'prefix': 'mgr services', - }) - if mgr_services: - mgr_endpoints = json.loads(mgr_services) - for end_point in mgr_endpoints.values(): - port = re.search(r'\:\d+\/', end_point) - if port: - ports.append(int(port[0][1:-1])) - + ports = self._get_mgr_service_ports(self.mgr) # Always replace ports (do not append onto a list rehydrated from the # persisted host cache). When ``mgr services`` is empty, ``ports`` is # empty and we must not retain old entries + append service discovery diff --git a/src/pybind/mgr/cephadm/tests/services/test_mgr.py b/src/pybind/mgr/cephadm/tests/services/test_mgr.py index 591a79ecc72..6a01a9aee68 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_mgr.py +++ b/src/pybind/mgr/cephadm/tests/services/test_mgr.py @@ -1,3 +1,4 @@ +import json from unittest import mock from cephadm.module import CephadmOrchestrator @@ -13,8 +14,8 @@ class TestMgrService: carried_over_ports: list) -> CephadmDaemonDeploySpec: """Build a daemon_spec that mimics rehydration from the persisted DaemonDescription (which copies `ports=dd.ports`), then call - MgrService.prepare_create with `mgr services` returning the supplied - payload. + MgrService.prepare_create with mgr_map returning the supplied + services payload. """ svc = MgrService(cephadm_module) daemon_spec = CephadmDaemonDeploySpec( @@ -24,9 +25,9 @@ class TestMgrService: daemon_type='mgr', ports=list(carried_over_ports), ) - with mock.patch.object(cephadm_module, 'check_mon_command', - return_value=(0, mgr_services_payload, '')), \ - mock.patch.object(svc, 'get_keyring_with_caps', + services = json.loads(mgr_services_payload) if mgr_services_payload else {} + cephadm_module.mock_store_set('_ceph_get', 'mgr_map', {'services': services}) + with mock.patch.object(svc, 'get_keyring_with_caps', return_value='[mgr.ceph-1.xyz]\n\tkey = X\n'), \ mock.patch.object(svc, 'generate_config', return_value=({}, [])): @@ -65,3 +66,25 @@ class TestMgrService: carried, ) assert out.ports == [9283, cephadm_module.service_discovery_port] + + def test_get_dependencies_changes_when_module_enabled( + self, cephadm_module: CephadmOrchestrator): + # Verify that get_dependencies reflects the current active modules. + # This is the mechanism that causes the serve loop to detect port + # changes and trigger a reconfig when modules are enabled/disabled. + + # Before: no modules enabled + cephadm_module.mock_store_set('_ceph_get', 'mgr_map', {'services': {}}) + deps_before = MgrService.get_dependencies(cephadm_module) + assert deps_before == [f'sd_port:{cephadm_module.service_discovery_port}'] + + # After: prometheus enabled + cephadm_module.mock_store_set('_ceph_get', 'mgr_map', { + 'services': {'prometheus': 'http://192.0.2.10:9283/'} + }) + deps_after = MgrService.get_dependencies(cephadm_module) + assert deps_after == ['port:9283', f'sd_port:{cephadm_module.service_discovery_port}'] + + # The difference between deps_before and deps_after is what the serve + # loop detects -- this is what triggers the reconfig + assert deps_before != deps_after From e55244161992640e2e4a487b1cbaa702f2cebf43 Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Sun, 28 Jun 2026 14:42:07 +0000 Subject: [PATCH 452/596] build: add Mold linker compatibility Mold handles several linker features differently from ld.bfd: - --exclude-libs,ALL hides .symver-aliased symbols (e.g. rados_*) even when the version script lists them as global. Detect Mold (via USING_MOLD_LINKER) and disable --exclude-libs; version scripts already control symbol visibility. - Duplicate symbols across static archives are treated as errors. Merge crimson-alien-common into crimson-alienstore as a single archive. For crimson-osd, link crimson-alienstore before crimson-common with --allow-multiple-definition so the alien thread's non-crimson definitions win (Mold picks first definition). - Python/Cython extension builds (via Distutils.cmake) invoked the system default linker. Pass -fuse-ld= via MOLD_FUSE_LD_FLAG so extensions link with the configured linker. - Add rados_* and _rados_* to librados.map global section for Mold compatibility with .symver aliases. Co-authored-by: Mark Kogan Signed-off-by: Ronen Friedman --- cmake/modules/CephChecks.cmake | 9 ++++++ cmake/modules/Distutils.cmake | 12 +++++-- src/crimson/os/alienstore/CMakeLists.txt | 23 +++++++++----- src/crimson/osd/CMakeLists.txt | 40 ++++++++++++++++++------ src/librados/librados.map | 2 ++ 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/cmake/modules/CephChecks.cmake b/cmake/modules/CephChecks.cmake index 4da4dfad5bf..9506a4db220 100644 --- a/cmake/modules/CephChecks.cmake +++ b/cmake/modules/CephChecks.cmake @@ -202,3 +202,12 @@ try_compile(HAVE_LINK_EXCLUDE_LIBS ${CMAKE_CURRENT_BINARY_DIR} SOURCES ${CMAKE_CURRENT_LIST_DIR}/CephCheck_link.c LINK_LIBRARIES "-Wl,--exclude-libs,ALL") + +# Mold linker applies --exclude-libs after version script processing, +# which hides .symver-aliased symbols (e.g. rados_*) even when the +# version script lists them as global. Disable --exclude-libs for Mold; +# version scripts already control symbol visibility. +if(HAVE_LINK_EXCLUDE_LIBS AND USING_MOLD_LINKER) + message(STATUS "Mold linker -- disabling --exclude-libs (incompatible with .symver)") + set(HAVE_LINK_EXCLUDE_LIBS FALSE CACHE INTERNAL "" FORCE) +endif() diff --git a/cmake/modules/Distutils.cmake b/cmake/modules/Distutils.cmake index 37b2efa7457..9d5dd33fcef 100644 --- a/cmake/modules/Distutils.cmake +++ b/cmake/modules/Distutils.cmake @@ -72,7 +72,11 @@ function(distutils_add_cython_module target name src) list(APPEND PY_CPPFLAGS -D'__Pyx_check_single_interpreter\(ARG\)=ARG\#\#0') set(PY_CC ${compiler_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1}) set(PY_CXX ${compiler_launcher} ${CMAKE_CXX_COMPILER} ${cxx_compiler_arg1}) - set(PY_LDSHARED ${link_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1} "-shared") + if(USING_MOLD_LINKER) + set(PY_LDSHARED ${link_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1} "-shared" "${MOLD_FUSE_LD_FLAG}") + else() + set(PY_LDSHARED ${link_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1} "-shared") + endif() string(REPLACE " " ";" PY_LDFLAGS "${CMAKE_SHARED_LINKER_FLAGS}") list(APPEND PY_LDFLAGS -L${CMAKE_LIBRARY_OUTPUT_DIRECTORY}) @@ -117,7 +121,11 @@ function(distutils_install_cython_module name) get_property(compiler_launcher GLOBAL PROPERTY RULE_LAUNCH_COMPILE) get_property(link_launcher GLOBAL PROPERTY RULE_LAUNCH_LINK) set(PY_CC "${compiler_launcher} ${CMAKE_C_COMPILER}") - set(PY_LDSHARED "${link_launcher} ${CMAKE_C_COMPILER} -shared") + if(USING_MOLD_LINKER) + set(PY_LDSHARED "${link_launcher} ${CMAKE_C_COMPILER} -shared ${MOLD_FUSE_LD_FLAG}") + else() + set(PY_LDSHARED "${link_launcher} ${CMAKE_C_COMPILER} -shared") + endif() set(PY_LDFLAGS "${CMAKE_SHARED_LINKER_FLAGS} -L${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") cmake_parse_arguments(DU "DISABLE_VTA" "" "" ${ARGN}) if(DU_DISABLE_VTA AND HAS_VTA) diff --git a/src/crimson/os/alienstore/CMakeLists.txt b/src/crimson/os/alienstore/CMakeLists.txt index 6a227cfdb87..0c87e1505e5 100644 --- a/src/crimson/os/alienstore/CMakeLists.txt +++ b/src/crimson/os/alienstore/CMakeLists.txt @@ -30,11 +30,6 @@ if(WITH_CEPH_DEBUG_MUTEX) ${PROJECT_SOURCE_DIR}/src/common/condition_variable_debug.cc ${PROJECT_SOURCE_DIR}/src/common/shared_mutex_debug.cc) endif() -add_library(crimson-alien-common STATIC - ${crimson_alien_common_srcs}) -if(WITH_BREAKPAD) - target_link_libraries(crimson-alien-common Breakpad::client) -endif() set(alien_store_srcs alien_store.cc @@ -65,20 +60,32 @@ set(alien_store_srcs ${PROJECT_SOURCE_DIR}/src/os/bluestore/OnodeScan.cc ${PROJECT_SOURCE_DIR}/src/os/memstore/MemStore.cc) +# Merge alienstore + alien-common into a single archive. The common sources +# are compiled without WITH_CRIMSON and duplicate symbols in crimson-common. +# ld.bfd resolves these by archive order; Mold treats them as errors. +# After building, localize the duplicate symbols so they resolve internally +# within this archive (for the alien/bluestore thread) while crimson-osd's +# own references resolve from crimson-common (for seastar threads). add_library(crimson-alienstore STATIC - ${alien_store_srcs}) + ${alien_store_srcs} + ${crimson_alien_common_srcs}) if(WITH_LTTNG) add_dependencies(crimson-alienstore bluestore-tp) endif() +if(WITH_BREAKPAD) + target_link_libraries(crimson-alienstore PRIVATE Breakpad::client) +endif() -# For crimson-alienstore WITH_CRIMSON is not defined target_link_libraries(crimson-alienstore PRIVATE seastar ${FMT_LIB} kv heap_profiler - crimson-alien-common ${BLKID_LIBRARIES} ${UDEV_LIBRARIES} blk) + +# Mold duplicate-symbol handling is done at the crimson-osd link step +# (see src/crimson/osd/CMakeLists.txt) by ordering crimson-alienstore +# before crimson-common with --allow-multiple-definition. diff --git a/src/crimson/osd/CMakeLists.txt b/src/crimson/osd/CMakeLists.txt index 7e06215a6eb..a15adf049cc 100644 --- a/src/crimson/osd/CMakeLists.txt +++ b/src/crimson/osd/CMakeLists.txt @@ -70,16 +70,36 @@ if(HAS_VTA) set_source_files_properties(main.cc PROPERTIES COMPILE_FLAGS -fno-var-tracking-assignments) endif() -target_link_libraries(crimson-osd - legacy-option-headers - crimson-admin - crimson-common - crimson-os - crimson - erasure_code - ${FMT_LIB} - Boost::MPL - dmclock::dmclock) +if(USING_MOLD_LINKER AND WITH_BLUESTORE) + # With Mold, duplicate symbols between crimson-alienstore and crimson-common + # must be resolved in favor of crimson-alienstore (compiled without + # WITH_CRIMSON) so the alien thread uses the classic code paths. + # Link crimson-alienstore BEFORE crimson-common so its definitions win + # with --allow-multiple-definition (Mold picks first definition). + target_link_libraries(crimson-osd + legacy-option-headers + crimson-admin + crimson-alienstore + crimson-common + crimson-os + crimson + erasure_code + ${FMT_LIB} + Boost::MPL + dmclock::dmclock) + target_link_options(crimson-osd PRIVATE -Wl,--allow-multiple-definition) +else() + target_link_libraries(crimson-osd + legacy-option-headers + crimson-admin + crimson-common + crimson-os + crimson + erasure_code + ${FMT_LIB} + Boost::MPL + dmclock::dmclock) +endif() set_target_properties(crimson-osd PROPERTIES POSITION_INDEPENDENT_CODE ${EXE_LINKER_USE_PIE}) install(TARGETS crimson-osd DESTINATION bin) diff --git a/src/librados/librados.map b/src/librados/librados.map index 279a0ba0691..96dd5072d94 100644 --- a/src/librados/librados.map +++ b/src/librados/librados.map @@ -1,5 +1,7 @@ LIBRADOS_PRIVATE { global: + rados_*; + _rados_*; extern "C++" { "guard variable for boost::asio::detail::call_stack::top_"; "guard variable for boost::asio::detail::call_stack::top_"; From ebc18499afd52ff43614cbf3c1f10c4713707d0f Mon Sep 17 00:00:00 2001 From: Ronen Friedman Date: Sun, 28 Jun 2026 14:42:23 +0000 Subject: [PATCH 453/596] cmake: add WITH_MOLD option for Mold linker support Add a WITH_MOLD cmake option that auto-configures the build for the Mold linker. When enabled, cmake finds mold, sets CMAKE_LINKER, injects -fuse-ld=mold into all linker flag variables, and sets USING_MOLD_LINKER which gates the Mold-specific workarounds added in the previous commit. Usage: cmake -DWITH_MOLD=ON ... Assisted-by: Claude Signed-off-by: Ronen Friedman --- CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index c02a5021ccc..b173c638cd0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,6 +75,20 @@ if(MINGW) link_directories(${MINGW_LINK_DIRECTORIES}) endif() +option(WITH_MOLD "Use the Mold linker" OFF) +if(WITH_MOLD) + find_program(_mold_path mold REQUIRED) + message(STATUS "Mold linker: ${_mold_path}") + set(CMAKE_LINKER ${_mold_path} CACHE FILEPATH "Linker" FORCE) + set(MOLD_FUSE_LD_FLAG "-fuse-ld=mold" CACHE INTERNAL "") + foreach(_flags CMAKE_EXE_LINKER_FLAGS CMAKE_SHARED_LINKER_FLAGS CMAKE_MODULE_LINKER_FLAGS) + if(NOT "${${_flags}}" MATCHES "fuse-ld=") + string(APPEND ${_flags} " ${MOLD_FUSE_LD_FLAG}") + endif() + endforeach() + set(USING_MOLD_LINKER TRUE CACHE INTERNAL "") +endif() + option(WITH_CCACHE "Build with ccache.") if(WITH_CCACHE) if(CMAKE_C_COMPILER_LAUNCHER OR CMAKE_CXX_COMPILER_LAUNCHER) From 68ec2a1b3fc20074615aad422b3b5642651b4ee6 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 29 Jun 2026 09:19:02 +0800 Subject: [PATCH 454/596] crimson/test: stop the messenger-thrash config on every exit path test_messenger_thrash put sharded_conf().stop() in the success continuation, so an exception from the tests skipped it and left the static sharded holding live instances. its destructor then asserts. a tester hit this when the run went OOM at --memory 256M: ERROR [shard 0:main] test - Test failed: got exception ceph::buffer::v15_2_0::bad_alloc (Bad allocation [buffer:1]) /ceph/src/seastar/include/seastar/core/sharded.hh:573: seastar::sharded::~sharded() [with Service = crimson::common::ConfigProxy]: Assertion `_instances.empty()' failed. terminate called without an active exception run the body in seastar::async and stop the config through seastar::deferred_stop, as crimson/osd/main.cc already does. the guard is constructed only after start() succeeds, and its destructor stops the config on any exit path, so a failing test exits cleanly instead of aborting. Signed-off-by: Kefu Chai --- src/test/crimson/test_messenger_thrash.cc | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/test/crimson/test_messenger_thrash.cc b/src/test/crimson/test_messenger_thrash.cc index ebc4a383ab2..36af35739ce 100644 --- a/src/test/crimson/test_messenger_thrash.cc +++ b/src/test/crimson/test_messenger_thrash.cc @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include "common/ceph_argparse.h" #include "messages/MPing.h" @@ -636,31 +638,24 @@ seastar::future do_test(seastar::app_template& app) CEPH_ENTITY_TYPE_CLIENT, &cluster, &conf_file_list); - return crimson::common::sharded_conf().start( - init_params.name, cluster - ).then([] { - return local_conf().start(); - }).then([conf_file_list] { - return local_conf().parse_config_files(conf_file_list); - }).then([&app] { - auto&& config = app.configuration(); - verbose = config["verbose"].as(); - return test_stress(thrash_params_t{8, 32, 50, 120}) - .then([] { - return test_injection(thrash_params_t{16, 32, 50, 120}); - }).then([] { + return seastar::async([init_params, cluster, conf_file_list, &app] { + try { + crimson::common::sharded_conf().start(init_params.name, cluster).get(); + local_conf().start().get(); + auto stop_conf = seastar::deferred_stop(crimson::common::sharded_conf()); + local_conf().parse_config_files(conf_file_list).get(); + verbose = app.configuration()["verbose"].as(); + test_stress(thrash_params_t{8, 32, 50, 120}).get(); + test_injection(thrash_params_t{16, 32, 50, 120}).get(); logger().info("All tests succeeded"); // Seastar has bugs to have events undispatched during shutdown, // which will result in memory leak and thus fail LeakSanitizer. - return seastar::sleep(100ms); - }); - }).then([] { - return crimson::common::sharded_conf().stop(); - }).then([] { - return 0; - }).handle_exception([] (auto eptr) { - logger().error("Test failed: got exception {}", eptr); - return 1; + seastar::sleep(100ms).get(); + return 0; + } catch (...) { + logger().error("Test failed: got exception {}", std::current_exception()); + return 1; + } }); } From 1ccb7561823812430d8e780ae277cbca619d81b3 Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Mon, 29 Jun 2026 09:36:12 +0800 Subject: [PATCH 455/596] qa/cephfs: fix invalid escape sequence SyntaxWarnings python 3.12 warns on unrecognized escape sequences in string literals. fuse_mount's admin-socket pyscript and mount's nft payload carry regex and shell escapes (\., \d, \;) that are meant literally, so make those strings raw. filesystem's get_mds_addr docstring had a stray \/ in the example address, which is just a slash, so drop the backslash. Signed-off-by: Kefu Chai --- qa/tasks/cephfs/filesystem.py | 2 +- qa/tasks/cephfs/fuse_mount.py | 4 ++-- qa/tasks/cephfs/mount.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qa/tasks/cephfs/filesystem.py b/qa/tasks/cephfs/filesystem.py index 0ff5a28475c..9c48134d0bf 100644 --- a/qa/tasks/cephfs/filesystem.py +++ b/qa/tasks/cephfs/filesystem.py @@ -192,7 +192,7 @@ class FSStatus(RunCephCmd): def get_mds_addr(self, name): """ - Return the instance addr as a string, like "10.214.133.138:6807\/10825" + Return the instance addr as a string, like "10.214.133.138:6807/10825" """ info = self.get_mds(name) if info: diff --git a/qa/tasks/cephfs/fuse_mount.py b/qa/tasks/cephfs/fuse_mount.py index 9df5accd696..35e2644c8c5 100644 --- a/qa/tasks/cephfs/fuse_mount.py +++ b/qa/tasks/cephfs/fuse_mount.py @@ -432,7 +432,7 @@ class FuseMountBase(CephFSMountBase): return "" def find_admin_socket(self): - pyscript = """ + pyscript = r""" import glob import re import os @@ -449,7 +449,7 @@ def _find_admin_socket(client_name): return files[0] for f in files: - pid = re.match(".*\.(\d+)\.asok$", f).group(1) + pid = re.match(r".*\.(\d+)\.asok$", f).group(1) if os.path.exists("/proc/{{0}}".format(pid)): with open("/proc/{{0}}/cmdline".format(pid), 'r') as proc_f: contents = proc_f.read() diff --git a/qa/tasks/cephfs/mount.py b/qa/tasks/cephfs/mount.py index 26b848b477a..2f083caddce 100644 --- a/qa/tasks/cephfs/mount.py +++ b/qa/tasks/cephfs/mount.py @@ -306,7 +306,7 @@ class CephFSMountBase(object): # Setup the NAT gw = self._default_gateway() - self.run_shell_payload(f""" + self.run_shell_payload(rf""" set -e if command -v iptables >/dev/null 2>&1 && sudo iptables -t nat -A POSTROUTING -s {self.ceph_brx_net} -o {gw} -j MASQUERADE 2>/dev/null; then From 69cd81f14718e62d8c8c71900813086beed415f4 Mon Sep 17 00:00:00 2001 From: Xuehan Xu Date: Fri, 5 Jun 2026 17:19:13 +0800 Subject: [PATCH 456/596] crimson/os/seastore/journal/circular_bounded_journal: find the real journal tail on startup Recover the correct journal tail during replay by scanning JOURNAL_TAIL records instead of relying solely on the on-disk header, which may be stale if a crash occurs between record write and header update. The journal header is stored *separately* from the (append-only) journal and is not updated atomically with journal writes, so it may lag behind after a crash. Treat the journal as the source of truth and rebuild the tail from persisted records. Replay is reorganized into three passes: 1) discover latest journal tail 2) rebuild allocation map 3) replay deltas Signed-off-by: Xuehan Xu --- .../journal/circular_bounded_journal.cc | 26 +++++++++++++++---- .../seastore/journal/circular_journal_space.h | 17 ++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/crimson/os/seastore/journal/circular_bounded_journal.cc b/src/crimson/os/seastore/journal/circular_bounded_journal.cc index 62e6c175aed..4bb31172fd4 100644 --- a/src/crimson/os/seastore/journal/circular_bounded_journal.cc +++ b/src/crimson/os/seastore/journal/circular_bounded_journal.cc @@ -343,6 +343,25 @@ Journal::replay_ret CircularBoundedJournal::replay( cjs.set_initialized(true); std::map map; std::map> crc_info; + auto tail = get_dirty_tail() <= get_alloc_tail() ? + get_dirty_tail() : get_alloc_tail(); + set_written_to(tail); + // the first pass to find the real journal tail. + auto find_tail = [this]( + const auto&, + const auto &e, + sea_time_point) { + if (e.type == extent_types_t::JOURNAL_TAIL) { + journal_tail_delta_t tails; + decode(tails, e.bl); + cjs.update_journal_tail_on_startup( + tails.dirty_tail, tails.alloc_tail); + } + return replay_ertr::make_ready_future(true); + }; + co_await scan_valid_record_delta(std::move(find_tail), tail); + tail = get_dirty_tail() <= get_alloc_tail() ? + get_dirty_tail() : get_alloc_tail(); auto build_paddr_seq_map = [&map]( const auto &offsets, const auto &e, @@ -359,10 +378,7 @@ Journal::replay_ret CircularBoundedJournal::replay( } return replay_ertr::make_ready_future(true); }; - auto tail = get_dirty_tail() <= get_alloc_tail() ? - get_dirty_tail() : get_alloc_tail(); - set_written_to(tail); - // The first pass to build the paddr->journal_seq_t map + // The second pass to build the paddr->journal_seq_t map // from extent allocations co_await scan_valid_record_delta(std::move(build_paddr_seq_map), tail); auto call_d_handler_if_valid = [this, &map, &d_handler, &crc_info]( @@ -390,7 +406,7 @@ Journal::replay_ret CircularBoundedJournal::replay( } return replay_ertr::make_ready_future(true); }; - // The second pass to replay deltas + // The third pass to replay deltas co_await scan_valid_record_delta(std::move(call_d_handler_if_valid), tail); for (auto p : crc_info) { ceph_assert_always(p.second.first->get_last_committed_crc() == p.second.second); diff --git a/src/crimson/os/seastore/journal/circular_journal_space.h b/src/crimson/os/seastore/journal/circular_journal_space.h index 049b276b19a..3a3ad2f483b 100644 --- a/src/crimson/os/seastore/journal/circular_journal_space.h +++ b/src/crimson/os/seastore/journal/circular_journal_space.h @@ -220,6 +220,23 @@ class CircularJournalSpace : public JournalAllocator { return device->read(offset, bptr); } + void update_journal_tail_on_startup( + journal_seq_t dirty, + journal_seq_t alloc) { + LOG_PREFIX(CircularJournalSpace); + SUBDEBUG(seastore_journal, + "update tail during replay: dirty={} alloc={}", + dirty, alloc); + if (dirty > header.dirty_tail || + header.dirty_tail == JOURNAL_SEQ_NULL) { + header.dirty_tail = dirty; + } + if (alloc >= header.alloc_tail || + header.alloc_tail == JOURNAL_SEQ_NULL) { + header.alloc_tail = alloc; + } + } + seastar::future<> update_journal_tail( journal_seq_t dirty, journal_seq_t alloc) { From c0e9ddabe5efdf91e29365ce7a8ad6d82455c1bb Mon Sep 17 00:00:00 2001 From: Naman Munet Date: Wed, 24 Jun 2026 19:33:54 +0530 Subject: [PATCH 457/596] mgr/dashboard: persist table selection across server side page changes Fixes: https://tracker.ceph.com/issues/77651 Signed-off-by: Naman Munet --- .../shared/datatable/table/table.component.ts | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index a261212f324..39386aba981 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -1265,43 +1265,78 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr * or some selected items may have been removed. */ updateSelected() { - if (!this.selection?.selected?.length) return; + // In server-side mode, if data is empty or not yet loaded, preserve existing selection + // This prevents clearing selection during page transitions when data is being fetched + if (this.serverSide && (!this.data || this.data.length === 0)) { + return; + } + + const itemsFoundOnCurrentPage = new Set(); + const itemsFromOtherPages = new Set(); - const newSelected = new Set(); this.selection.selected.forEach((selectedItem) => { + let foundOnCurrentPage = false; for (const row of this.data) { if (selectedItem[this.identifier] === row[this.identifier]) { - newSelected.add(row); + itemsFoundOnCurrentPage.add(row); + foundOnCurrentPage = true; + break; } } + // For server-side pagination, preserve items not on current page + if (!foundOnCurrentPage && this.serverSide) { + itemsFromOtherPages.add(selectedItem); + } }); - const newSelectedArray = Array.from(newSelected.values()); + const selectedItemsOnCurrentPage = Array.from(itemsFoundOnCurrentPage.values()); + const selectedItemsOnOtherPages = Array.from(itemsFromOtherPages.values()); - if (newSelectedArray.length === 0) { + // For server-side pagination, if no items found on current page but we have + // items from other pages, preserve them + if ( + selectedItemsOnCurrentPage.length === 0 && + selectedItemsOnOtherPages.length > 0 && + this.serverSide + ) { + // No items on current page are selected, but we have selections from other pages + // Keep the selection with items from other pages + this.selection.selected = selectedItemsOnOtherPages; + if (this.updateSelectionOnRefresh !== 'never') { + this.updateSelection.emit(_.clone(this.selection)); + } + return; + } + + if (selectedItemsOnCurrentPage.length === 0) { this.selection.selected = []; this.updateSelection.emit(_.clone(this.selection)); return; } - newSelectedArray.forEach((selection: any) => { + selectedItemsOnCurrentPage.forEach((selectedItem: any) => { const rowIndex = this.model.data.findIndex( (row: TableItem[]) => - _.get(row, [0, 'selected', this.identifier]) === selection[this.identifier] + _.get(row, [0, 'selected', this.identifier]) === selectedItem[this.identifier] ); if (rowIndex > -1) { this.model.selectRow(rowIndex, true); } }); + // For server-side pagination, combine found items with items from other pages + const finalSelection = this.serverSide + ? [...selectedItemsOnCurrentPage, ...selectedItemsOnOtherPages] + : selectedItemsOnCurrentPage; + if ( this.updateSelectionOnRefresh === 'onChange' && - _.isEqual(this.selection.selected, newSelectedArray) + _.isEqual(this.selection.selected, finalSelection) ) { return; } - this.selection.selected = newSelectedArray; + this.selection.selected = finalSelection; if (this.updateSelectionOnRefresh !== 'never') { this.updateSelection.emit(_.clone(this.selection)); From 0a528685d598bc43e4124a6bd039bd768ed50374 Mon Sep 17 00:00:00 2001 From: Redouane Kachach Date: Mon, 29 Jun 2026 14:20:22 +0200 Subject: [PATCH 458/596] mgr/cephadm: bump Python version path mapped in kcli dev env Bump Python version path mapped in kcli dev env from 3.9 to 3.12 Signed-off-by: Redouane Kachach --- src/cephadm/cephadmlib/daemons/ceph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cephadm/cephadmlib/daemons/ceph.py b/src/cephadm/cephadmlib/daemons/ceph.py index 0663f1d47a3..7fc83f007ef 100644 --- a/src/cephadm/cephadmlib/daemons/ceph.py +++ b/src/cephadm/cephadmlib/daemons/ceph.py @@ -600,13 +600,13 @@ def get_ceph_mounts_for_type( mounts[cephadm_binary] = '/usr/sbin/cephadm' mounts[ ceph_folder + '/src/ceph-volume/ceph_volume' - ] = '/usr/lib/python3.9/site-packages/ceph_volume' + ] = '/usr/lib/python3.12/site-packages/ceph_volume' mounts[ ceph_folder + '/src/pybind/mgr' ] = '/usr/share/ceph/mgr' mounts[ ceph_folder + '/src/python-common/ceph' - ] = '/usr/lib/python3.9/site-packages/ceph' + ] = '/usr/lib/python3.12/site-packages/ceph' mounts[ ceph_folder + '/monitoring/ceph-mixin/dashboards_out' ] = '/etc/grafana/dashboards/ceph-dashboard' From ce787df56bf8e2017e8fd6c1a3d04b33de429fb7 Mon Sep 17 00:00:00 2001 From: Nithya Balachandran Date: Sun, 14 Jun 2026 12:43:43 +0000 Subject: [PATCH 459/596] rgw/posix: fix delete markers Creates 0 byte files for delete markers for versioned buckets. Signed-off-by: Nithya Balachandran --- src/rgw/driver/posix/rgw_sal_posix.cc | 165 ++++++++++++++++++++------ src/rgw/driver/posix/rgw_sal_posix.h | 16 ++- 2 files changed, 136 insertions(+), 45 deletions(-) diff --git a/src/rgw/driver/posix/rgw_sal_posix.cc b/src/rgw/driver/posix/rgw_sal_posix.cc index 92710d01669..3a0704bb7d2 100644 --- a/src/rgw/driver/posix/rgw_sal_posix.cc +++ b/src/rgw/driver/posix/rgw_sal_posix.cc @@ -36,6 +36,7 @@ const std::string ATTR_PREFIX = "user.X-RGW-"; #define RGW_POSIX_ATTR_OWNER "POSIX-Owner" #define RGW_POSIX_ATTR_OBJECT_TYPE "POSIX-Object-Type" #define RGW_POSIX_ATTR_MANIFEST "POSIX-Manifest" +#define RGW_POSIX_ATTR_VERSION "POSIX-version" const std::string mp_ns = "multipart"; const std::string MP_OBJ_PART_PFX = "part-"; const std::string MP_OBJ_HEAD_NAME = MP_OBJ_PART_PFX + "00000"; @@ -454,6 +455,9 @@ int FSEnt::fill_cache(const DoutPrefixProvider *dpp, optional_yield y, fill_cach if (flags & FSEnt::FLAG_CURRENT) { bde.flags |= rgw_bucket_dir_entry::FLAG_CURRENT; } + if (flags & FSEnt::FLAG_DELETE_MARKER) { + bde.flags |= rgw_bucket_dir_entry::FLAG_DELETE_MARKER; + } break; case ObjectType::MULTIPART: case ObjectType::DIRECTORY: @@ -672,7 +676,7 @@ int File::copy(const DoutPrefixProvider *dpp, optional_yield y, std::unique_ptr del; ret = dst_dir->get_ent(dpp, y, dst_name, std::string(), del); if (ret >= 0) { - ret = del->remove(dpp, y, /*delete_children=*/true); + ret = del->remove(dpp, y, /*delete_children=*/true, nullptr); if (ret < 0) { ldpp_dout(dpp, 0) << "ERROR: could not remove dest " << dst_name << dendl; @@ -709,7 +713,7 @@ int File::copy(const DoutPrefixProvider *dpp, optional_yield y, return 0; } -int File::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) +int File::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) { if (!exists()) { return 0; @@ -852,7 +856,7 @@ int Directory::stat(const DoutPrefixProvider* dpp, bool force) return 0; } -int Directory::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) +int Directory::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) { return delete_directory(parent->get_fd(), fname.c_str(), delete_children, dpp); } @@ -988,7 +992,7 @@ int Directory::copy(const DoutPrefixProvider *dpp, optional_yield y, std::unique_ptr del; ret = dst_dir->get_ent(dpp, y, dst_name, std::string(), del); if (ret >= 0) { - ret = del->remove(dpp, y, /*delete_children=*/true); + ret = del->remove(dpp, y, /*delete_children=*/true, nullptr); if (ret < 0) { ldpp_dout(dpp, 0) << "ERROR: could not remove dest " << dst_name << dendl; @@ -1295,9 +1299,9 @@ int MPDirectory::link_temp_file(const DoutPrefixProvider *dpp, optional_yield y, return rename(dpp, y, parent, savename); } -int MPDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) +int MPDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) { - return Directory::remove(dpp, y, /*delete_children=*/true); + return Directory::remove(dpp, y, /*delete_children=*/true, result); } int MPDirectory::stat(const DoutPrefixProvider* dpp, bool force) @@ -1461,7 +1465,7 @@ int VersionedDirectory::set_cur_version_ent(const DoutPrefixProvider* dpp, FSEnt std::unique_ptr del; int ret = get_ent(dpp, null_yield, get_name(), std::string(), del); if (ret >= 0) { - ret = del->remove(dpp, null_yield, /*delete_children=*/true); + ret = del->remove(dpp, null_yield, /*delete_children=*/true, nullptr); if (ret < 0) { ldpp_dout(dpp, 0) << "ERROR: could not remove cur_version " << get_name() << dendl; @@ -1520,9 +1524,6 @@ int VersionedDirectory::stat(const DoutPrefixProvider* dpp, bool force) cur_version = sl->get_target()->clone_base(); ret = cur_version->open(dpp); if (ret < 0) { - /* If target doesn't exist, it's a delete marker */ - cur_version.reset(); - stx.stx_size = 0; return 0; } ret = cur_version->stat(dpp); @@ -1530,6 +1531,19 @@ int VersionedDirectory::stat(const DoutPrefixProvider* dpp, bool force) return ret; stx.stx_size = cur_version->get_stx().stx_size; + if (cur_version->get_stx().stx_size == 0) { + //Possibly a delete marker + Attrs attrs; + ret = cur_version->read_attrs(dpp, null_yield, attrs); + if (ret < 0) { + return ret; + } + bufferlist bl; + if (rgw::sal::get_attr(attrs, RGW_POSIX_ATTR_VERSION, bl)) { + // cur_version.reset(); + } + } + return 0; } @@ -1603,7 +1617,7 @@ int VersionedDirectory::copy(const DoutPrefixProvider *dpp, optional_yield y, std::unique_ptr del; ret = dst_dir->get_ent(dpp, y, basename, std::string(), del); if (ret >= 0) { - ret = del->remove(dpp, y, /*delete_children=*/true); + ret = del->remove(dpp, y, /*delete_children=*/true, nullptr); if (ret < 0) { ldpp_dout(dpp, 0) << "ERROR: could not remove dest " << basename << dendl; @@ -1666,7 +1680,46 @@ int VersionedDirectory::copy(const DoutPrefixProvider *dpp, optional_yield y, return ret; } -int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) +int VersionedDirectory::add_delete_marker(const DoutPrefixProvider* dpp, + optional_yield y, + std::unique_ptr& marker, + std::string& name) +{ + //TODO: Create a tmp file + int ret = marker->create(dpp, /*existed=*/nullptr, /*temp_file=*/false); + if (ret < 0) { + return ret; + } + + // XXX: Hack to set the owner on the delete marker + Attrs v_attrs; + Attrs attrs; + + ret = get_x_attrs(y, dpp, get_fd(), v_attrs, get_name()); + if (ret < 0) { + return ret; + } + + bufferlist owner_bl; + if (rgw::sal::get_attr(v_attrs, RGW_POSIX_ATTR_OWNER, owner_bl)) { + attrs[RGW_POSIX_ATTR_OWNER] = std::move(owner_bl); + } + buffer::list bl; + uint16_t flags = 0; + flags |= rgw_bucket_dir_entry::FLAG_DELETE_MARKER; + ceph::encode(flags, bl); + attrs[RGW_POSIX_ATTR_VERSION] = std::move(bl); + ret = marker->write_attrs(dpp, y, attrs, nullptr); + if (ret < 0) { + //TODO: Delete the marker file + return ret; + } + + return 0; +} + +int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, + bool delete_children, DeleteResult* result) { std::string tgtname; bool newlink = false; @@ -1683,18 +1736,31 @@ int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, if (ret == 0) { /* We're empty, nuke us */ - return Directory::remove(dpp, y, /*delete_children=*/true); + return Directory::remove(dpp, y, /*delete_children=*/true, result); } /* Add a delete marker */ + std::unique_ptr f; rgw_obj_key key = decode_obj_key(get_name()); key.instance = gen_rand_instance_name(); tgtname = get_key_fname(key, /*use_version=*/true); - newlink = true; - ret = remove_symlink(dpp, y); + + result->delete_marker = true; + result->version_id = key.instance; + + f = std::make_unique(tgtname, this, ctx); + ret = add_delete_marker(dpp, y, f, tgtname); if (ret < 0) { return ret; } + + newlink = true; + ret = set_cur_version_ent(dpp, f.get()); + if (ret < 0) { + return ret; + } + cur_version = std::move(f); + return 0; } else { /* Delete specific version */ rgw_obj_key key = decode_obj_key(get_name()); @@ -1707,25 +1773,22 @@ int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, ret = f->stat(dpp); if (ret < 0) return ret; - ret = f->remove(dpp, y, /*delete_children=*/true); - if (ret < 0) + Attrs attrs; + ret = f->read_attrs(dpp, y, attrs); + if (ret < 0) { return ret; - } else if (ret == -ENOENT) { - /* See if we're removing a delete marker */ - std::unique_ptr sl = - std::make_unique(get_name(), this, ctx); - ret = sl->stat(dpp); - if (ret == 0) { - if (name != sl->get_target()->get_name()) { - /* Symlink didn't match, don't change anything */ - return 0; - } } - /* FALLTHROUGH */ + bufferlist bl; + if (get_attr(attrs, RGW_POSIX_ATTR_VERSION, bl)) { + result->delete_marker = true; + } + ret = f->remove(dpp, y, /*delete_children=*/true, nullptr); + if (ret < 0) + return ret; + result->version_id = instance_id; } else { return ret; } - /* Possibly move symlink */ ret = remove_symlink(dpp, y, name); if (ret < 0) { @@ -1749,16 +1812,23 @@ int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, if (tgtname.empty()) { /* We're empty, nuke us */ exist = false; - return Directory::remove(dpp, y, /*delete_children=*/true); + return Directory::remove(dpp, y, /*delete_children=*/true, result); } } - if (newlink) { exist = true; - std::unique_ptr sl = - std::make_unique(get_name(), this, tgtname, ctx); - return sl->create(dpp, /*existed=*/nullptr, /*temp_file=*/false); + std::unique_ptr f; + ret = get_ent(dpp, y, tgtname, std::string(), f); + if (ret < 0) { + return ret; + } + ret = set_cur_version_ent(dpp, f.get()); + if (ret < 0) { + return ret; + } + cur_version = std::move(f); } + return 0; } @@ -1789,6 +1859,19 @@ int VersionedDirectory::fill_cache(const DoutPrefixProvider *dpp, optional_yield FSEnt::FLAG_CURRENT : FSEnt::FLAG_NONE; + // Delete markers are zero byte files + if (ent->get_stx().stx_size == 0) { + Attrs attrs; + bufferlist bl; + ret = ent->read_attrs(dpp, y, attrs); + if (ret < 0) { + return ret; + } + if (get_attr(attrs, RGW_POSIX_ATTR_VERSION, bl)) { + fill_flags |= FSEnt::FLAG_DELETE_MARKER; + } + } + ret = ent->fill_cache(dpp, y, cb, fill_flags); if (ret < 0) return ret; @@ -1834,7 +1917,7 @@ int VersionedDirectory::remove_symlink(const DoutPrefixProvider *dpp, optional_y return -ENOKEY; } - ret = sl->remove(dpp, y, /*delete_children=*/false); + ret = sl->remove(dpp, y, /*delete_children=*/false, nullptr); if (ret < 0) { return ret; } @@ -2768,7 +2851,7 @@ int POSIXBucket::remove(const DoutPrefixProvider* dpp, bool delete_children, optional_yield y) { - int ret = dir->remove(dpp, y, delete_children); + int ret = dir->remove(dpp, y, delete_children, nullptr); if (ret < 0) { return ret; } @@ -3137,8 +3220,7 @@ int POSIXObject::delete_object(const DoutPrefixProvider* dpp, } return ret; } - - ret = ent->remove(dpp, y, /*delete_children=*/false); + ret = ent->remove(dpp, y, /*delete_children=*/false, &del_result); cls_rgw_obj_key key; get_key().get_index_key(&key); @@ -3964,7 +4046,12 @@ int POSIXObject::POSIXReadOp::get_attr(const DoutPrefixProvider* dpp, const char int POSIXObject::POSIXDeleteOp::delete_obj(const DoutPrefixProvider* dpp, optional_yield y, uint32_t flags) { - return source->delete_object(dpp, y, flags, nullptr, nullptr); + int ret = source->delete_object(dpp, y, flags, nullptr, nullptr); + if (ret < 0) { + return ret; + } + result = source->get_result(); + return 0; } int POSIXObject::copy(const DoutPrefixProvider *dpp, optional_yield y, diff --git a/src/rgw/driver/posix/rgw_sal_posix.h b/src/rgw/driver/posix/rgw_sal_posix.h index 7779991ee82..b3caeade70e 100644 --- a/src/rgw/driver/posix/rgw_sal_posix.h +++ b/src/rgw/driver/posix/rgw_sal_posix.h @@ -29,7 +29,7 @@ namespace rgw { namespace sal { class POSIXDriver; class POSIXBucket; class POSIXObject; - +using DeleteResult = rgw::sal::Object::DeleteOp::Result; using BucketCache = file::listing::BucketCache; /* integration w/bucket listing cache */ @@ -110,6 +110,7 @@ protected: public: static constexpr uint32_t FLAG_NONE = 0x0; static constexpr uint32_t FLAG_CURRENT = 0x2; + static constexpr uint32_t FLAG_DELETE_MARKER = 0x4; FSEnt(std::string _name, Directory* _parent, CephContext* _ctx) : fname(_name), parent(_parent), ctx(_ctx) {} FSEnt(std::string _name, Directory* _parent, struct statx& _stx, CephContext* _ctx) : fname(_name), parent(_parent), exist(true), stx(_stx), stat_done(true), ctx(_ctx) {} @@ -135,7 +136,7 @@ public: virtual int open(const DoutPrefixProvider *dpp) = 0; virtual int close() = 0; virtual int stat(const DoutPrefixProvider *dpp, bool force = false); - virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) = 0; + virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) = 0; virtual int write(int64_t ofs, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) = 0; virtual int read(int64_t ofs, int64_t end, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) = 0; virtual int write_attrs(const DoutPrefixProvider* dpp, optional_yield y, Attrs& attrs, Attrs* extra_attrs); @@ -166,7 +167,7 @@ public: virtual int open(const DoutPrefixProvider *dpp) override; virtual int close() override; virtual int stat(const DoutPrefixProvider *dpp, bool force = false) override; - virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) override; + virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) override; virtual int write(int64_t ofs, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) override; virtual int read(int64_t ofs, int64_t end, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) override; virtual int copy(const DoutPrefixProvider *dpp, optional_yield y, Directory* dst_dir, const std::string& name) override; @@ -198,7 +199,7 @@ public: virtual int open(const DoutPrefixProvider *dpp) override; virtual int close() override; virtual int stat(const DoutPrefixProvider *dpp, bool force = false) override; - virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) override; + virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) override; template int for_each(const DoutPrefixProvider* dpp, const F& func); virtual int rename(const DoutPrefixProvider* dpp, optional_yield y, Directory* dst_dir, std::string dst_name); @@ -275,7 +276,7 @@ public: virtual int create(const DoutPrefixProvider *dpp, bool* existed = nullptr, bool temp_file = false) override; virtual int read(int64_t ofs, int64_t end, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) override; virtual int link_temp_file(const DoutPrefixProvider* dpp, optional_yield y, std::string target_fname) override; - virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) override; + virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) override; virtual int stat(const DoutPrefixProvider *dpp, bool force = false) override; std::unique_ptr get_part_file(int partnum); virtual std::unique_ptr clone_base() override { @@ -331,11 +332,12 @@ public: virtual int write(int64_t ofs, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) override; virtual int read(int64_t ofs, int64_t end, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y) override; virtual int link_temp_file(const DoutPrefixProvider* dpp, optional_yield y, std::string target_fname) override; - virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children) override; + virtual int remove(const DoutPrefixProvider* dpp, optional_yield y, bool delete_children, DeleteResult* result) override; virtual std::string get_cur_version() override; std::string get_new_instance(); int remove_symlink(const DoutPrefixProvider *dpp, optional_yield y, std::string match = ""); int add_file(const DoutPrefixProvider *dpp, std::unique_ptr&& file, bool* existed = nullptr, bool temp_file = false); + int add_delete_marker(const DoutPrefixProvider* dpp, optional_yield y, std::unique_ptr& marker, std::string& name); FSEnt* get_cur_version_ent() { return cur_version.get(); }; int set_cur_version_ent(const DoutPrefixProvider *dpp, FSEnt* file); virtual std::unique_ptr clone_base() override { @@ -998,6 +1000,7 @@ private: RGWAccessControlPolicy acls; std::unique_ptr ent; std::map parts; + DeleteResult del_result; public: struct POSIXReadOp : ReadOp { @@ -1149,6 +1152,7 @@ public: int stat(const DoutPrefixProvider *dpp); int make_ent(ObjectType type); bool versioned() { return bucket->versioned(); } + DeleteResult get_result() {return del_result;} protected: int read(int64_t ofs, int64_t end, bufferlist& bl, const DoutPrefixProvider* dpp, optional_yield y); From 2e892f3c5ce718e6410ce84ce33eab671225f6e8 Mon Sep 17 00:00:00 2001 From: Ali Masarwa Date: Mon, 22 Jun 2026 13:58:47 +0300 Subject: [PATCH 460/596] rgw/posix: fix delete markers Signed-off-by: Ali Masarwa --- src/rgw/driver/posix/rgw_sal_posix.cc | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/rgw/driver/posix/rgw_sal_posix.cc b/src/rgw/driver/posix/rgw_sal_posix.cc index 3a0704bb7d2..a7c097f82fe 100644 --- a/src/rgw/driver/posix/rgw_sal_posix.cc +++ b/src/rgw/driver/posix/rgw_sal_posix.cc @@ -1685,18 +1685,20 @@ int VersionedDirectory::add_delete_marker(const DoutPrefixProvider* dpp, std::unique_ptr& marker, std::string& name) { - //TODO: Create a tmp file - int ret = marker->create(dpp, /*existed=*/nullptr, /*temp_file=*/false); + // Create as temporary file first + int ret = marker->create(dpp, /*existed=*/nullptr, /*temp_file=*/true); if (ret < 0) { return ret; } - // XXX: Hack to set the owner on the delete marker + // XXX: Hack to set the owner on the delete marker Attrs v_attrs; Attrs attrs; ret = get_x_attrs(y, dpp, get_fd(), v_attrs, get_name()); if (ret < 0) { + // removing the temporary files before returning failure + marker->remove(dpp, y, /*delete_children=*/false, nullptr); return ret; } @@ -1704,14 +1706,26 @@ int VersionedDirectory::add_delete_marker(const DoutPrefixProvider* dpp, if (rgw::sal::get_attr(v_attrs, RGW_POSIX_ATTR_OWNER, owner_bl)) { attrs[RGW_POSIX_ATTR_OWNER] = std::move(owner_bl); } + buffer::list bl; uint16_t flags = 0; flags |= rgw_bucket_dir_entry::FLAG_DELETE_MARKER; ceph::encode(flags, bl); attrs[RGW_POSIX_ATTR_VERSION] = std::move(bl); + + // Write attributes before linking ret = marker->write_attrs(dpp, y, attrs, nullptr); if (ret < 0) { - //TODO: Delete the marker file + // removing the temporary files before returning failure + marker->remove(dpp, y, /*delete_children=*/false, nullptr); + return ret; + } + + // Link temp file to final name atomically + ret = marker->link_temp_file(dpp, y, name); + if (ret < 0) { + // removing the temporary files before returning failure + marker->remove(dpp, y, /*delete_children=*/false, nullptr); return ret; } @@ -1782,7 +1796,7 @@ int VersionedDirectory::remove(const DoutPrefixProvider* dpp, optional_yield y, if (get_attr(attrs, RGW_POSIX_ATTR_VERSION, bl)) { result->delete_marker = true; } - ret = f->remove(dpp, y, /*delete_children=*/true, nullptr); + ret = f->remove(dpp, y, /*delete_children=*/true, result); if (ret < 0) return ret; result->version_id = instance_id; From d46aea099ef134fda8f26737d517ddc78886c76f Mon Sep 17 00:00:00 2001 From: Alex Ainscow Date: Sun, 3 May 2026 17:46:00 +0100 Subject: [PATCH 461/596] test/osd: Add scrub to osd test fixtures Add scrubbing functionality to the OSD test fixtures to enable testing of scrub behavior on erasure-coded pools. This implementation uses as much of the scrub backend as possible, but avoids using pg_scrubber or the interaction with peering due to the complexity of the dependency on PG. Changes: - Add scrub_object() method to PGBackendTestFixture for testing scrub functionality on EC pools - Add create_random_buffer() utility for generating random test data - Add corrupt_shard_data() method to intentionally corrupt shard data for testing scrub detection - Add MockScrubBeListener, MockPgScrubBeListener, MockSnapMapReader, and MockLoggerSinkSet mock classes to support scrub testing - Add three new test cases: ScrubClean, ScrubDetectsCorruption, and ScrubPartialWrite to verify scrub behavior with clean data, corrupted data, and partial writes Signed-off-by: Alex Ainscow # Conflicts: # src/test/osd/TestECFailoverWithPeering.cc --- src/test/osd/PGBackendTestFixture.cc | 165 ++++++++++++++++++ src/test/osd/PGBackendTestFixture.h | 51 +++++- src/test/osd/ScrubTestFixture.h | 195 ++++++++++++++++++++++ src/test/osd/TestBackendBasics.cc | 2 + src/test/osd/TestECFailoverWithPeering.cc | 119 ++++++++++++- 5 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 src/test/osd/ScrubTestFixture.h diff --git a/src/test/osd/PGBackendTestFixture.cc b/src/test/osd/PGBackendTestFixture.cc index a3c26585be7..72b565f908e 100644 --- a/src/test/osd/PGBackendTestFixture.cc +++ b/src/test/osd/PGBackendTestFixture.cc @@ -25,6 +25,12 @@ #include "messages/MOSDPGPush.h" #include "messages/MOSDPGPushReply.h" +void PGBackendTestFixture::initialize_scrub_infra() +{ + scrub_listener = TestScrubBackend::create_scrub_listener(spgid, osdmap); + snap_reader = TestScrubBackend::create_snap_reader(); +} + void PGBackendTestFixture::setup_ec_pool() { CephContext *cct = g_ceph_context; @@ -904,3 +910,162 @@ object_info_t PGBackendTestFixture::read_shard_object_info( oi.decode(p); return oi; } + + +bool PGBackendTestFixture::scrub_object(const std::string& obj_name) +{ + hobject_t hoid = make_test_object(obj_name); + + int total_shards = k + m; + std::map scrub_maps; + + for (int shard = 0; shard < total_shards; ++shard) { + pg_shard_t pg_shard(shard, shard_id_t(shard)); + ScrubMap& smap = scrub_maps[pg_shard]; + + auto backend_it = backends.find(shard); + if (backend_it == backends.end()) { + std::cerr << "ERROR: Backend for shard " << shard << " does not exist" << std::endl; + return true; + } + PGBackend* backend = backend_it->second.get(); + if (!backend) { + std::cerr << "ERROR: Backend pointer for shard " << shard << " is null" << std::endl; + return true; + } + + ScrubMapBuilder pos; + pos.ls.push_back(hoid); + pos.pos = 0; + pos.deep = true; + + const Scrub::ScrubCounterSet& counters = Scrub::io_counters_ec; + + int r = backend->be_scan_list(counters, smap, pos); + while (r == -EINPROGRESS) { + r = backend->be_scan_list(counters, smap, pos); + } + + if (r != 0) { + std::cerr << "ERROR: be_scan_list failed for shard " << shard << ": " << cpp_strerror(r) << std::endl; + return true; + } + + if (!smap.objects.contains(hoid)) { + std::cerr << "ERROR: Object not in scrub map for shard " << shard << std::endl; + return true; + } + } + + if (!scrub_listener || !snap_reader) { + initialize_scrub_infra(); + } + + ceph_assert(scrub_listener); + ceph_assert(snap_reader); + + MockPGBackendListener* primary_listener = get_primary_listener(); + if (!primary_listener) { + std::cerr << "ERROR: No primary listener found" << std::endl; + return true; + } + int primary_shard = primary_listener->pg_whoami.osd; + + PGBackend* primary_backend = get_primary_backend(); + if (!primary_backend) { + std::cerr << "ERROR: No primary backend found" << std::endl; + return true; + } + + const pg_pool_t* pool_info = osdmap->get_pg_pool(pool_id); + ceph_assert(pool_info != nullptr); + + MockPgScrubBeListener pg_scrub_listener(primary_backend); + pg_scrub_listener.pool = std::make_shared( + osdmap, pool_id, *pool_info, "test_pool"); + pg_scrub_listener.primary = primary_shard; + pg_scrub_listener.info.pgid = spgid; + + std::set acting_set; + for (int i = 0; i < k + m; ++i) { + acting_set.insert(pg_shard_t(i, shard_id_t(i))); + } + + TestScrubBackend scrub_backend(*scrub_listener, pg_scrub_listener, + pg_shard_t(primary_shard, shard_id_t(primary_shard)), + false, scrub_level_t::deep, acting_set); + + scrub_backend.new_chunk(); + + for (const auto& [pg_shard, smap] : scrub_maps) { + scrub_backend.insert_faked_smap(pg_shard, smap); + } + + auto result = scrub_backend.scrub_compare_maps(false, *snap_reader); + + return !result.inconsistent_objs.empty(); +} + + +bufferlist PGBackendTestFixture::create_random_buffer(size_t size) +{ + bufferlist bl; + + if (size == 0) { + return bl; + } + + ceph::buffer::ptr bp(size); + + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dis(0, 255); + + for (size_t i = 0; i < size; ++i) { + bp[i] = dis(gen); + } + + bl.append(std::move(bp)); + return bl; +} + +void PGBackendTestFixture::corrupt_shard_data(const hobject_t& obj, pg_shard_t shard) +{ + auto ch_it = chs.find(shard.osd); + if (ch_it == chs.end()) { + std::cerr << "ERROR: No collection handle for shard " << shard.osd << std::endl; + return; + } + + ghobject_t ghoid(obj, ghobject_t::NO_GEN, shard.shard); + + struct stat st; + int r = store->stat(ch_it->second, ghoid, &st); + if (r < 0) { + std::cerr << "ERROR: Failed to stat object on shard " << shard.osd + << ": " << cpp_strerror(r) << std::endl; + return; + } + + uint64_t size = st.st_size; + if (size == 0) { + std::cerr << "WARNING: Object has zero size on shard " << shard.osd << std::endl; + return; + } + + bufferlist zero_bl; + zero_bl.append_zero(size); + + ObjectStore::Transaction t; + t.write(ch_it->second->cid, ghoid, 0, size, zero_bl); + + r = store->queue_transaction(ch_it->second, std::move(t)); + if (r < 0) { + std::cerr << "ERROR: Failed to corrupt object on shard " << shard.osd + << ": " << cpp_strerror(r) << std::endl; + return; + } + + std::cout << "Corrupted shard " << shard.osd << " data for object " << obj + << " (wrote " << size << " bytes of zeros)" << std::endl; +} diff --git a/src/test/osd/PGBackendTestFixture.h b/src/test/osd/PGBackendTestFixture.h index 0e728f3f38c..cb7bfc155ea 100644 --- a/src/test/osd/PGBackendTestFixture.h +++ b/src/test/osd/PGBackendTestFixture.h @@ -40,6 +40,9 @@ #include "os/ObjectStore.h" #include "erasure-code/ErasureCodePlugin.h" #include "test/osd/OSDMapTestHelpers.h" +#include "test/osd/ScrubTestFixture.h" +#include "osd/scrubber/scrub_backend.h" +#include "osd/scrubber/pg_scrubber.h" // Unified test fixture for EC and Replicated backend tests with ObjectStore. // Uses PoolType to branch between EC (ECSwitch) and Replicated (ReplicatedBackend). @@ -86,7 +89,9 @@ protected: // OpTracker for wrapping messages in OpRequestRef std::shared_ptr op_tracker; - + // Scrub infrastructure - initialized once and reused across scrub operations + std::unique_ptr scrub_listener; + std::unique_ptr snap_reader; ceph::ErasureCodeInterfaceRef ec_impl; std::map> lrus; int k = 4; // data chunks @@ -214,6 +219,9 @@ private: void setup_replicated_pool(); void cleanup_data_dir(); +protected: + void initialize_scrub_infra(); + public: const pg_pool_t& get_pool() const { const pg_pool_t* pool = OSDMapTestHelpers::get_pool(osdmap, pool_id); @@ -505,5 +513,46 @@ public: const std::string& obj_name, int shard); + /** + * Scrub an object and verify it has no corruption. + * + * This utility method: + * 1. Builds scrub maps for all shards using be_scan_list() + * 2. Creates a ScrubBackend with mock listeners + * 3. Calls scrub_compare_maps() to check for inconsistencies + * 4. Returns true if corruption was detected, false otherwise + * + * The scrub infrastructure (mock listeners) is initialized once in SetUp() + * and reused across all scrub operations for efficiency. + * + * @param obj_name Name of the object to scrub + * @return true if corruption detected, false if object is consistent + */ + bool scrub_object(const std::string& obj_name); + + /** + * Corrupt the data for a specific shard of an object. + * + * This utility method directly writes zeros to the stored data for a given + * shard, simulating data corruption at the storage level. This is useful + * for testing scrub detection of corrupted data. + * + * @param obj The hobject_t identifying the object to corrupt + * @param shard The pg_shard_t identifying which shard to corrupt + */ + void corrupt_shard_data(const hobject_t& obj, pg_shard_t shard); + + /** + * Create a bufferlist filled with random data. + * + * This utility method generates a buffer of the specified size filled with + * random bytes. Useful for testing scenarios where random data is needed + * to avoid patterns that might mask bugs (e.g., XOR patterns in EC). + * + * @param size Size of the buffer to create in bytes + * @return A bufferlist containing random data + */ + bufferlist create_random_buffer(size_t size); + }; diff --git a/src/test/osd/ScrubTestFixture.h b/src/test/osd/ScrubTestFixture.h new file mode 100644 index 00000000000..2fb3768a928 --- /dev/null +++ b/src/test/osd/ScrubTestFixture.h @@ -0,0 +1,195 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- +// vim: ts=8 sw=2 sts=2 expandtab + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2026 IBM + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * + */ + +#pragma once + +#include +#include + +#include "include/expected.hpp" +#include "osd/scrubber/scrub_backend.h" +#include "osd/scrubber/pg_scrubber.h" +#include "osd/PGBackend.h" +#include "osd/PeeringState.h" +#include "osd/osd_types.h" +#include "test/osd/scrubber_generators.h" +#include "global/global_context.h" + +// Type alias for compatibility with existing code +using MockLoggerSinkSet = ScrubGenerator::MockLog; + +/** + * MockScrubBeListener - Mock implementation of ScrubBeListener for testing + * + * Provides minimal implementation of the ScrubBeListener interface for + * testing scrub backend functionality. + */ +class MockScrubBeListener : public ScrubBeListener { + public: + MockScrubBeListener() + : pg_whoami(0, shard_id_t::NO_SHARD), + primary_shard(0, shard_id_t::NO_SHARD) {} + + MockScrubBeListener(pg_shard_t whoami, pg_shard_t primary) + : pg_whoami(whoami), primary_shard(primary) {} + + std::ostream& gen_prefix(std::ostream& out) const override { + return out << "scrub: "; + } + CephContext* get_pg_cct() const override { return g_ceph_context; } + LoggerSinkSet& get_logger() const override { return logger; } + bool is_primary() const override { return pg_whoami == primary_shard; } + spg_t get_pgid() const override { return pgid; } + const OSDMapRef& get_osdmap() const override { return osdmap; } + void add_to_stats(const object_stat_sum_t&) override {} + void submit_digest_fixes(const digests_fixes_t&) override {} + + pg_shard_t pg_whoami; // This shard's identity + pg_shard_t primary_shard; // The primary shard's identity + spg_t pgid; + OSDMapRef osdmap; + mutable MockLoggerSinkSet logger; +}; + +/** + * MockPgScrubBeListener - Mock implementation of Scrub::PgScrubBeListener for testing + * + * Provides EC-aware implementation of the PgScrubBeListener interface for + * testing scrub backend functionality with erasure-coded pools. + * + * Key features: + * - Delegates EC operations (encode/decode) to real ECBackend for correctness + * - Implements logical_to_ondisk_size() for proper EC shard size calculation + * - Delegates get_is_nonprimary_shard() to handle multi-zone EC pools correctly + */ +class MockPgScrubBeListener : public Scrub::PgScrubBeListener { + public: + MockPgScrubBeListener(PGBackend* be = nullptr) : backend(be) {} + + const PGPool& get_pgpool() const override { return *pool; } + + pg_shard_t get_primary() const override { + return pg_shard_t(primary, shard_id_t(primary)); + } + + void force_object_missing(ScrubberPasskey, + const std::set& peer, + const hobject_t& oid, + eversion_t version) override {} + + const pg_info_t& get_pg_info(ScrubberPasskey) const override { return info; } + + uint64_t logical_to_ondisk_size(uint64_t logical_size, shard_id_t shard_id, + bool object_is_legacy_ec) const override { + return backend->be_get_ondisk_size(logical_size, shard_id_t(shard_id), + object_is_legacy_ec); + } + + bool ec_can_decode(const shard_id_set& available_shards) const override { + return backend->ec_can_decode(available_shards); + } + + shard_id_map ec_encode_acting_set(const bufferlist& bl) const override { + return backend->ec_encode_acting_set(bl); + } + + shard_id_map ec_decode_acting_set( + const shard_id_map& shard_map, + int chunk_size) const override { + return backend->ec_decode_acting_set(shard_map, chunk_size); + } + + bool get_ec_supports_crc_encode_decode() const override { + return backend->get_ec_supports_crc_encode_decode(); + } + + ECUtil::stripe_info_t get_ec_sinfo() const override { + return backend->ec_get_sinfo(); + } + + bool is_waiting_for_unreadable_object() const override { return false; } + + bool get_is_nonprimary_shard(const pg_shard_t& shard) const override { + return backend->get_is_nonprimary_shard(shard.shard); + } + + bool get_is_hinfo_required() const override { + return backend->get_is_hinfo_required(); + } + + bool get_is_ec_optimized() const override { + return backend->get_is_ec_optimized(); + } + + std::shared_ptr pool; + int primary; + pg_info_t info; + PGBackend* backend; +}; + +/** + * MockSnapMapReader - Mock implementation of Scrub::SnapMapReaderI for testing + * + * Provides minimal implementation that returns empty snapshot sets, + * suitable for testing scrub functionality without snapshot complexity. + */ +class MockSnapMapReader : public Scrub::SnapMapReaderI { + public: + tl::expected, result_t> get_snaps( + const hobject_t&) const override { + return std::set{}; + } + tl::expected, result_t> get_snaps_check_consistency( + const hobject_t&) const override { + return std::set{}; + } +}; + +// Test helper to access ScrubBackend internals +class TestScrubBackend : public ScrubBackend { +public: + TestScrubBackend(ScrubBeListener& scrubber, + Scrub::PgScrubBeListener& pg, + pg_shard_t i_am, + bool repair, + scrub_level_t shallow_or_deep, + const std::set& acting) + : ScrubBackend(scrubber, pg, i_am, repair, shallow_or_deep, acting) + {} + + void insert_faked_smap(pg_shard_t shard, const ScrubMap& smap) { + ceph_assert(this_chunk.has_value()); + this_chunk->received_maps[shard] = smap; + } + + // Factory method to create MockScrubBeListener with proper initialization + static std::unique_ptr create_scrub_listener( + const spg_t& pgid, + const OSDMapRef& osdmap) + { + auto listener = std::make_unique(); + listener->pgid = pgid; + listener->osdmap = osdmap; + return listener; + } + + // Factory method to create MockSnapMapReader + static std::unique_ptr create_snap_reader() + { + return std::make_unique(); + } +}; + +// Made with Bob diff --git a/src/test/osd/TestBackendBasics.cc b/src/test/osd/TestBackendBasics.cc index 8e544c46a5f..36d367fa076 100644 --- a/src/test/osd/TestBackendBasics.cc +++ b/src/test/osd/TestBackendBasics.cc @@ -79,6 +79,7 @@ public: void SetUp() override { PGBackendTestFixture::SetUp(); + initialize_scrub_infra(); } /** @@ -474,6 +475,7 @@ public: void SetUp() override { PGBackendTestFixture::SetUp(); + initialize_scrub_infra(); } void simulate_osd_failure(int failed_osd, int new_primary_instance) diff --git a/src/test/osd/TestECFailoverWithPeering.cc b/src/test/osd/TestECFailoverWithPeering.cc index 9a9cd0f24c9..c9aab2adcf3 100644 --- a/src/test/osd/TestECFailoverWithPeering.cc +++ b/src/test/osd/TestECFailoverWithPeering.cc @@ -16,7 +16,6 @@ #include #include "test/osd/ECPeeringTestFixture.h" #include "test/osd/TestCommon.h" -#include "osd/ECSwitch.h" using namespace std; @@ -843,6 +842,124 @@ TEST_P( set_config("osd_async_recovery_min_cost", "100"); } +TEST_P(TestECFailoverWithPeering, ScrubClean) { + ASSERT_TRUE(all_shards_active()) << "Initial peering must complete"; + + const std::string obj_name = "test_scrub_corruption"; + uint64_t object_size = k * stripe_unit; + + bufferlist bl = create_random_buffer(object_size); + std::string test_data(bl.c_str(), bl.length()); + + std::cout << "Writing full-stripe object (" << object_size << " bytes of random data)" << std::endl; + create_and_write_verify(obj_name, test_data); + + std::cout << "Scrubbing object to verify data integrity" << std::endl; + bool corruption_detected = scrub_object(obj_name); + + ASSERT_FALSE(corruption_detected) + << "scrub_object() should NOT detect corruption when data is valid"; + + std::cout << "=== ScrubDetectsCorruption test completed successfully ===" << std::endl; +} + +TEST_P(TestECFailoverWithPeering, ScrubDetectsCorruption) { + ASSERT_TRUE(all_shards_active()) << "Initial peering must complete"; + + const uint64_t object_size = k * stripe_unit; + const std::vector shard_offsets = {/*0, 1, */k}; + const bool supports_crc = ec_plugin == "isa"; + + for (int zone = 0; zone < 1; ++zone) { + for (int shard_offset : shard_offsets) { + const int absolute_shard = shard_offset; + const std::string obj_name = + "test_obj_zone_" + std::to_string(zone) + + "_shard_" + std::to_string(shard_offset); + + bufferlist bl = create_random_buffer(object_size); + std::string test_data(bl.c_str(), bl.length()); + + std::cout << "\n=== ScrubDetectsCorruption: testing zone " << zone + << ", shard offset " << shard_offset + << " (absolute shard " << absolute_shard << ") ===" << std::endl; + + std::cout << "Writing object " << obj_name << " (" << object_size + << " bytes of random data)" << std::endl; + create_and_write_verify(obj_name, test_data); + + std::cout << "Corrupting object " << obj_name + << " for zone iteration " << zone + << " on relative shard " << shard_offset + << " using absolute shard " << absolute_shard << std::endl; + hobject_t hoid = make_test_object(obj_name); + corrupt_shard_data(hoid, + pg_shard_t(absolute_shard, shard_id_t(absolute_shard))); + + std::cout << "Scrubbing object " << obj_name + << " to verify corruption detection for zone iteration " << zone + << ", shard offset " << shard_offset << std::endl; + bool corruption_detected = scrub_object(obj_name); + + std::cout << "Zone iteration " << zone + << " corruption result for shard offset " << shard_offset + << ": " << (corruption_detected ? "detected" : "not detected") + << " (absolute shard " << absolute_shard + << ", supports_crc=" << (supports_crc ? "true" : "false") + << ")" << std::endl; + + if (supports_crc) { + EXPECT_TRUE(corruption_detected) + << "scrub_object() should detect corruption for object " << obj_name + << " during zone iteration " << zone + << ", shard offset " << shard_offset + << " (absolute shard " << absolute_shard << ")"; + } else { + EXPECT_FALSE(corruption_detected) + << "scrub_object() should not report corruption for object " + << obj_name << " when CRC-based detection is unsupported" + << " during zone iteration " << zone << ", shard offset " + << shard_offset << " (absolute shard " << absolute_shard << ")"; + } + } + } + + std::cout << "=== ScrubDetectsCorruption test completed successfully ===" << std::endl; +} + +TEST_P(TestECFailoverWithPeering, ScrubPartialWrite) { + ASSERT_TRUE(all_shards_active()) << "Initial peering must complete"; + + const std::string obj_name = "test_scrub_partial_write"; + + uint64_t partial_size = stripe_unit / 2; + + std::cout << "Creating partial write object with size " << partial_size + << " bytes (stripe_unit=" << stripe_unit << ", full stripe would be " + << (k * stripe_unit) << " bytes)" << std::endl; + + bufferlist bl = create_random_buffer(partial_size); + std::string test_data(bl.c_str(), bl.length()); + + std::cout << "Writing partial object (" << partial_size << " bytes)" << std::endl; + create_and_write_verify(obj_name, test_data); + + write(obj_name, 0, test_data, test_data.size()); + + // NOTE: Partial writes may expose scrub issues with EC pools + std::cout << "Scrubbing partial write object to test scrub behavior" << std::endl; + bool corruption_detected = scrub_object(obj_name); + + std::cout << "Scrub result for partial write: " + << (corruption_detected ? "corruption detected" : "no corruption detected") + << std::endl; + + EXPECT_FALSE(corruption_detected) + << "scrub_object() should NOT detect corruption on valid partial write"; + + std::cout << "=== ScrubPartialWrite test completed ===" << std::endl; +} + // --------------------------------------------------------------------------- // Instantiate TestECFailoverWithPeering with EC configurations // --------------------------------------------------------------------------- From 832d6122e98cdff77e2ab2831c2f1c92ea74274d Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 29 Jun 2026 20:50:20 -0700 Subject: [PATCH 462/596] blk/aio: fix mismatched parenthesis in POSIX AIO submit path The condition `if ((cur->n_aiocb == 1) {` has an unbalanced parenthesis and fails to compile. This code is only built on the HAVE_POSIXAIO (BSD) path, so the breakage is normally hidden. Signed-off-by: Sun Yuechi --- src/blk/aio/aio.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blk/aio/aio.cc b/src/blk/aio/aio.cc index 6ade779b4c1..eeb93fbc87a 100644 --- a/src/blk/aio/aio.cc +++ b/src/blk/aio/aio.cc @@ -48,7 +48,7 @@ int aio_queue_t::submit_batch(aio_iter begin, aio_iter end, } #elif defined(HAVE_POSIXAIO) cur->priv = priv; - if ((cur->n_aiocb == 1) { + if (cur->n_aiocb == 1) { // TODO: consider batching multiple reads together with lio_listio cur->aio.aiocb.aio_sigevent.sigev_notify = SIGEV_KEVENT; cur->aio.aiocb.aio_sigevent.sigev_notify_kqueue = ctx; From ceff64b3a9200957da8586ede99aa855d547c52f Mon Sep 17 00:00:00 2001 From: Sun Yuechi Date: Mon, 29 Jun 2026 20:53:26 -0700 Subject: [PATCH 463/596] blk/spdk: call spdk_env_opts_init() before setting pci options spdk_env_opts_init() reinitializes the whole spdk_env_opts struct, overwriting pci_allowed/pci_whitelist and num_pci_addr that were set just above it. As a result the local PCI device restriction never took effect. Initialize opts first, then fill in the PCI address fields. Fixes: https://tracker.ceph.com/issues/77802 Signed-off-by: Sun Yuechi --- src/blk/spdk/NVMEDevice.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blk/spdk/NVMEDevice.cc b/src/blk/spdk/NVMEDevice.cc index dea2312156c..885aa765b45 100644 --- a/src/blk/spdk/NVMEDevice.cc +++ b/src/blk/spdk/NVMEDevice.cc @@ -593,6 +593,8 @@ int NVMEManager::try_get(const spdk_nvme_transport_id& trid, SharedDriverData ** struct spdk_pci_addr addr; int r; + spdk_env_opts_init(&opts); + bool local_pci_device = false; int rc = spdk_pci_addr_parse(&addr, trid.traddr); if (!rc) { @@ -605,7 +607,6 @@ int NVMEManager::try_get(const spdk_nvme_transport_id& trid, SharedDriverData ** opts.num_pci_addr = 1; } - spdk_env_opts_init(&opts); opts.name = "nvme-device-manager"; opts.core_mask = coremask_arg.c_str(); #if SPDK_VERSION >= SPDK_VERSION_NUM(21, 1, 0) From 3201a3d130e3b0d4498454db1db36c26f00d745a Mon Sep 17 00:00:00 2001 From: Kefu Chai Date: Sat, 13 Jun 2026 12:45:03 +0800 Subject: [PATCH 464/596] crimson/seastore: skip LBA-leaf crc check for in-place delta-overwrite extents on a cold read, check_full_extent_integrity() compares the crc of the read content against the crc stored in the LBA leaf (pin_crc). this aborts on a RANDOM_BLOCK data extent that was overwritten via delta-based overwrite: abort(extent checksum inconsistent) 4# Cache::check_full_extent_integrity() 5# Cache::_read_extent()::{lambda} 7# Cache::replay_delta() (or _read_pin on the live IO path) the leaf crc is not a valid reference for these extents. delta overwrite mutates the extent in place to avoid touching the LBA tree, so update_lba_mappings() skips the leaf for is_exist_mutation_pending() extents and the pre-mutation crc stays there. the new content lands on disk later and out-of-band, when the cleaner rewrites the modified region in place (RandomBlockOolWriter::do_write). that write is not atomic with the leaf, so a cold read can pick up new content against an old leaf crc. updating the leaf crc on overwrite is not the fix. it brings back the LBA-tree mutation (and the txn conflicts and hot-path crc) that delta overwrite exists to avoid, and it still does not make the leaf reliable: the data write and the leaf update happen separately, so publishing leaf=new while disk still holds old just moves the mismatch. so stop trusting the leaf for these extents. their content is verified at replay, where replay_delta() applies the deltas and CircularBoundedJournal::replay() asserts last_committed_crc == delta.final_crc. and the content on disk is always current: modified_region accumulates every change via union_insert and is cleared only when the in-place rewrite commits, so do_write writes the latest content; only the leaf crc lags. drop the leaf-crc check on read for RANDOM_BLOCK in-place-rewritable (data) extents, but only when delta-based overwrite is enabled. with it off (the default), overwrites go through remapping, the leaf crc stays valid, and the check still runs for every other extent. last_committed_crc is computed regardless, so the delta-chain check is unaffected. Signed-off-by: Kefu Chai --- src/crimson/os/seastore/cache.cc | 3 +++ src/crimson/os/seastore/cache.h | 31 +++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/crimson/os/seastore/cache.cc b/src/crimson/os/seastore/cache.cc index be90decca48..9500ffdbc61 100644 --- a/src/crimson/os/seastore/cache.cc +++ b/src/crimson/os/seastore/cache.cc @@ -33,6 +33,9 @@ Cache::Cache( ExtentPlacementManager &epm, store_index_t store_index) : epm(epm), + delta_based_overwrite_enabled( + crimson::common::get_conf( + "seastore_data_delta_based_overwrite") > 0), pinboard(create_extent_pinboard( crimson::common::get_conf( "seastore_cachepin_size_pershard"))) diff --git a/src/crimson/os/seastore/cache.h b/src/crimson/os/seastore/cache.h index 24aebba12f2..c878ad6d51f 100644 --- a/src/crimson/os/seastore/cache.h +++ b/src/crimson/os/seastore/cache.h @@ -1731,6 +1731,9 @@ private: } ExtentPlacementManager& epm; + // when delta-based overwrite is enabled, RANDOM_BLOCK data extents may be + // mutated in place, leaving their LBA-leaf crc stale; see _read_extent(). + const bool delta_based_overwrite_enabled; RootBlockRef root; ///< ref to current root ExtentIndex extents_index; ///< set of live extents @@ -2062,14 +2065,26 @@ void stage_visibility_handoff(Transaction& t, offset, length, *extent); if (pin_crc != CRC_NULL) { - SUBDEBUG(seastore_cache, "read extent 0x{:x}~0x{:x} veryfing integrity -- {}", - offset, length, *extent); - // We must check the integrity here prior to complete_io. - // Previously, concurrent transaction could have checked - // crc of non matching extent data. - // See: https://tracker.ceph.com/issues/73790 - assert(extent->is_fully_loaded()); - check_full_extent_integrity(extent->last_committed_crc, pin_crc); + // a RANDOM_BLOCK data extent that delta-based overwrite may have + // mutated in place has a stale LBA-leaf crc: its content and its + // leaf crc are updated through separate, non-atomic paths, so a + // cold read can see the new content against the old leaf crc. + // such extents are verified via the delta chain instead (see + // replay_delta()), so skip the leaf-crc check for them. + bool inplace_delta_overwrite = + delta_based_overwrite_enabled && + extent->get_paddr().is_absolute_random_block() && + can_inplace_rewrite(extent->get_type()); + if (!inplace_delta_overwrite) { + SUBDEBUG(seastore_cache, "read extent 0x{:x}~0x{:x} verifying integrity -- {}", + offset, length, *extent); + // We must check the integrity here prior to complete_io. + // Previously, concurrent transaction could have checked + // crc of non matching extent data. + // See: https://tracker.ceph.com/issues/73790 + assert(extent->is_fully_loaded()); + check_full_extent_integrity(extent->last_committed_crc, pin_crc); + } } } else { extent->last_committed_crc = CRC_NULL; From 68dc3eb81ed9cfcb93999fbbc4d8619994b014e3 Mon Sep 17 00:00:00 2001 From: Mohit Agrawal Date: Fri, 12 Jun 2026 07:29:18 +0530 Subject: [PATCH 465/596] core: Handle OP_TOUCH_TEMP in BlueStore without any guard(WITH_CRIMSON) During backfill recovery on Crimson OSD replica, Bluestore aborts with _txc_add_transaction error ENOENT on operation due to OP_TOUCH_TEMP. OP_TOUCH_TEMP is guarded by #ifdef WITH_CRIMSON but WITH_CRIMSON is not defined when BlueStore is compiled. OP_TOUCH_TEMP is a crimson specific op to manage temporary objects and the op is not used by classic osd. It is a plain integer constant defined in the shared transaction layer so adding it to the common code path in Transaction.h and BlueStore.cc requires no additional includes or compile time guards.Handling it unconditonally is therefore safe for all Objectstore backends. Solution: Handle OP_TOUCH_TEMP without any guard Fixes: https://tracker.ceph.com/issues/75957 Signed-off-by: Mohit Agrawal --- src/os/Transaction.cc | 2 -- src/os/Transaction.h | 7 +------ src/os/bluestore/BlueStore.cc | 4 ---- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/os/Transaction.cc b/src/os/Transaction.cc index 960dfd30191..21539e91171 100644 --- a/src/os/Transaction.cc +++ b/src/os/Transaction.cc @@ -87,7 +87,6 @@ void Transaction::dump(ceph::Formatter *f) } break; -#ifdef WITH_CRIMSON case Transaction::OP_TOUCH_TEMP: { coll_t cid = i.get_cid(op->cid); @@ -99,7 +98,6 @@ void Transaction::dump(ceph::Formatter *f) f->dump_stream("oid") << dest_oid; } break; -#endif case Transaction::OP_WRITE: { diff --git a/src/os/Transaction.h b/src/os/Transaction.h index d4c3623addd..220e701f45d 100644 --- a/src/os/Transaction.h +++ b/src/os/Transaction.h @@ -153,9 +153,7 @@ public: OP_COLL_SET_BITS = 42, // cid, bits OP_MERGE_COLLECTION = 43, // cid, destination -#ifdef WITH_CRIMSON OP_TOUCH_TEMP = 44, // cid, temp_oid, target_oid -#endif }; // Transaction hint type @@ -442,9 +440,7 @@ public: op->oid = om[op->oid]; break; -#ifdef WITH_CRIMSON case OP_TOUCH_TEMP: -#endif case OP_CLONERANGE2: case OP_CLONE: ceph_assert(op->cid < cm.size()); @@ -862,7 +858,6 @@ public: _op->oid = _get_object_id(oid); data.ops = data.ops + 1; } -#ifdef WITH_CRIMSON /** * touch_temp * @@ -884,7 +879,7 @@ public: _op->dest_oid = _get_object_id(target_oid); data.ops = data.ops + 1; } -#endif + /** * Write data to an offset within an object. If the object is too * small, it is expanded as needed. It is possible to specify an diff --git a/src/os/bluestore/BlueStore.cc b/src/os/bluestore/BlueStore.cc index bbfd74a2175..1720efc3d5b 100644 --- a/src/os/bluestore/BlueStore.cc +++ b/src/os/bluestore/BlueStore.cc @@ -16226,9 +16226,7 @@ void BlueStore::_txc_add_transaction(TransContext *txc, Transaction *t) // these operations implicity create the object bool create = false; if (op->op == Transaction::OP_TOUCH || -#ifdef WITH_CRIMSON op->op == Transaction::OP_TOUCH_TEMP || -#endif op->op == Transaction::OP_CREATE || op->op == Transaction::OP_WRITE || op->op == Transaction::OP_ZERO) { @@ -16252,9 +16250,7 @@ void BlueStore::_txc_add_transaction(TransContext *txc, Transaction *t) switch (op->op) { case Transaction::OP_CREATE: case Transaction::OP_TOUCH: -#ifdef WITH_CRIMSON case Transaction::OP_TOUCH_TEMP: -#endif r = _touch(txc, c, o); break; From fcac016ae429dd8d64f00fe3639582b547cc7e64 Mon Sep 17 00:00:00 2001 From: pujaoshahu Date: Thu, 2 Apr 2026 16:54:06 +0530 Subject: [PATCH 466/596] mgr/dashboard: Add option should be enabled even if allow all host access is enabled in nvme/tcp Fixes: https://tracker.ceph.com/issues/75509 Signed-off-by: pujaoshahu --- .../nvmeof-initiators-form.component.html | 1 + .../nvmeof-initiators-form.component.spec.ts | 20 ++- .../nvmeof-initiators-form.component.ts | 16 ++- .../nvmeof-initiators-list.component.html | 58 ++++---- .../nvmeof-initiators-list.component.spec.ts | 126 ++++++++++++++++-- .../nvmeof-initiators-list.component.ts | 81 ++++++++--- .../nvmeof-namespaces-list.component.spec.ts | 52 +++++++- .../nvmeof-subsystem-step-2.component.html | 1 + .../nvmeof-subsystem-step-2.component.ts | 2 +- .../nvmeof-subsystems.component.spec.ts | 35 +++++ 10 files changed, 331 insertions(+), 61 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html index 4b2df6992c5..a4c8a8a1a35 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html @@ -13,6 +13,7 @@ #tearsheetStep modal-primary-focus [group]="group" + [allowAllHosts]="allowAllHosts" [existingHosts]="existingHosts"> @if(showAuthStep) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts index e7f8f1f7251..388592eaea4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts @@ -2,7 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of } from 'rxjs'; @@ -31,7 +31,7 @@ describe('NvmeofInitiatorsFormComponent', () => { { provide: ActivatedRoute, useValue: { - queryParams: of({ group: 'test-group' }), + queryParamMap: of(convertToParamMap({ group: 'test-group' })), params: of({ subsystem_nqn: 'nqn.test' }), parent: { params: of({ subsystem_nqn: 'nqn.test' }) @@ -59,6 +59,22 @@ describe('NvmeofInitiatorsFormComponent', () => { expect(component).toBeTruthy(); }); + it('should set allowAllHosts to true when disableAllowAll is not set', () => { + const router = TestBed.inject(Router); + spyOn(router, 'getCurrentNavigation').and.returnValue(null); + component.ngOnInit(); + expect(component.allowAllHosts).toBe(true); + }); + + it('should set allowAllHosts to false when disableAllowAll is true in navigation state', () => { + const router = TestBed.inject(Router); + spyOn(router, 'getCurrentNavigation').and.returnValue({ + extras: { state: { disableAllowAll: true } } + } as any); + component.ngOnInit(); + expect(component.allowAllHosts).toBe(false); + }); + it('should initialize with two steps (Host access control + Authentication optional)', () => { expect(component.steps.length).toBe(2); expect(component.steps[0].label).toBe('Host access control'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts index d25ffa1368a..d3cb2308f39 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts @@ -9,7 +9,7 @@ import { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Params, Router } from '@angular/router'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; type InitiatorsFormPayload = Pick & @@ -32,6 +32,7 @@ export class NvmeofInitiatorsFormComponent implements OnInit { isSubmitLoading = false; existingHosts: string[] = []; showAuthStep = true; + allowAllHosts = true; stepTwoValue: HostStepType = null; @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent; @@ -50,15 +51,16 @@ export class NvmeofInitiatorsFormComponent implements OnInit { ) {} ngOnInit() { - this.route.queryParams.subscribe((params) => { - this.group = params?.['group']; + this.route.queryParamMap.subscribe((params) => { + this.group = params.get('group'); }); - this.route.parent.params.subscribe((params: any) => { + this.allowAllHosts = !this.getDisableAllowAllState(); + this.route.parent.params.subscribe((params: Params) => { if (params.subsystem_nqn) { this.subsystemNQN = params.subsystem_nqn; } }); - this.route.params.subscribe((params: any) => { + this.route.params.subscribe((params: Params) => { if (!this.subsystemNQN && params.subsystem_nqn) { this.subsystemNQN = params.subsystem_nqn; } @@ -67,6 +69,10 @@ export class NvmeofInitiatorsFormComponent implements OnInit { this.rebuildSteps(); } + private getDisableAllowAllState(): boolean { + return this.router.getCurrentNavigation()?.extras?.state?.['disableAllowAll'] === true; + } + rebuildSteps() { const steps: Step[] = [{ label: STEP_LABELS.HOSTS, invalid: false }]; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html index 8c924ce0152..0988d0563d5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html @@ -1,35 +1,43 @@ -@if (hasAllHostsAllowed()) { +@if (allowAllHosts) { -
- All hosts allowed -

- Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. -

+
+
+ Host access: All hosts allowed +

+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. +

+
+ Edit host access
+} @else { + +
+ + +
+
} - -
- - -
-
- {{ getDisplayedHostNqn(row.nqn) }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts index 05d956256cb..0f72f0225b6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts @@ -1,13 +1,14 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpClientModule } from '@angular/common/http'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { ModalService } from '~/app/shared/services/modal.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { ALLOW_ALL_HOST } from '~/app/shared/models/nvmeof'; @@ -15,7 +16,7 @@ import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.componen const mockInitiators = [ { - nqn: ALLOW_ALL_HOST, + nqn: 'nqn.2016-06.io.spdk:host1', use_dhchap: false } ]; @@ -23,7 +24,8 @@ const mockInitiators = [ const mockSubsystem = { nqn: 'nqn.2016-06.io.spdk:cnode1', serial_number: '12345', - has_dhchap_key: false + has_dhchap_key: false, + allow_any_host: false }; class MockNvmeOfService { @@ -41,26 +43,51 @@ class MockAuthStorageService { } } -class MockModalService {} +class MockModalCdsService {} class MockTaskWrapperService {} describe('NvmeofInitiatorsListComponent', () => { let component: NvmeofInitiatorsListComponent; let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let routeParams$: Subject; + let queryParams$: Subject; + let routerEvents$: Subject; beforeEach(async () => { + routeParams$ = new Subject(); + queryParams$ = new Subject(); + routerEvents$ = new Subject(); + await TestBed.configureTestingModule({ declarations: [NvmeofInitiatorsListComponent], imports: [HttpClientModule, RouterTestingModule, SharedModule], providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, - { provide: ModalService, useClass: MockModalService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: ModalCdsService, useClass: MockModalCdsService }, + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { + provide: ActivatedRoute, + useValue: { + parent: { params: routeParams$.asObservable() }, + queryParams: queryParams$.asObservable() + } + }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + navigate: jasmine.createSpy('navigate') + } + } ] }).compileComponents(); + }); + beforeEach(() => { + nvmeofService = TestBed.inject(NvmeofService); fixture = TestBed.createComponent(NvmeofInitiatorsListComponent); component = fixture.componentInstance; component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1'; @@ -84,9 +111,23 @@ describe('NvmeofInitiatorsListComponent', () => { expect(component.getDisplayedHostNqn(ALLOW_ALL_HOST)).toBe('Any'); })); + it('should default allowAllHosts to false', () => { + expect(component.allowAllHosts).toBe(false); + }); + + it('should set allowAllHosts to true when subsystem allows any host and no initiators', fakeAsync(() => { + const allowAllSubsystem = { ...mockSubsystem, allow_any_host: true }; + spyOn(nvmeofService, 'getInitiators').and.returnValue(of([])); + spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(allowAllSubsystem)); + component.listInitiators(); + component.getSubsystem(); + tick(); + expect(component.allowAllHosts).toBe(true); + })); + it('should update authStatus when initiator has dhchap_key', fakeAsync(() => { const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }]; - spyOn(TestBed.inject(NvmeofService), 'getInitiators').and.returnValue(of(initiatorsWithKey)); + spyOn(nvmeofService, 'getInitiators').and.returnValue(of(initiatorsWithKey)); component.listInitiators(); tick(); expect(component.authStatus).toBe('Unidirectional'); @@ -96,9 +137,76 @@ describe('NvmeofInitiatorsListComponent', () => { const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }]; component.initiators = initiatorsWithKey; const subsystemWithKey = { ...mockSubsystem, has_dhchap_key: true }; - spyOn(TestBed.inject(NvmeofService), 'getSubsystem').and.returnValue(of(subsystemWithKey)); + spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(subsystemWithKey)); component.getSubsystem(); tick(); expect(component.authStatus).toBe('Bi-directional'); })); + + it('should fetch only when both route and query params are available', () => { + const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent); + const newComponent = newFixture.componentInstance; + newComponent.subsystemNQN = undefined; + newComponent.group = undefined; + const listInitiatorsSpy = spyOn(newComponent, 'listInitiators'); + const getSubsystemSpy = spyOn(newComponent, 'getSubsystem'); + + newComponent.ngOnInit(); + + routeParams$.next({ subsystem_nqn: 'nqn.from.route' }); + expect(listInitiatorsSpy).not.toHaveBeenCalled(); + expect(getSubsystemSpy).not.toHaveBeenCalled(); + + queryParams$.next({ group: 'group-from-query' }); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(1); + expect(getSubsystemSpy).toHaveBeenCalledTimes(1); + }); + + it('should refresh on non-modal navigation changes', () => { + const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent); + const newComponent = newFixture.componentInstance; + newComponent.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1'; + newComponent.group = 'group1'; + const listInitiatorsSpy = spyOn(newComponent, 'listInitiators'); + const getSubsystemSpy = spyOn(newComponent, 'getSubsystem'); + + newComponent.ngOnInit(); + routerEvents$.next(new NavigationEnd(1, '/nvmeof/(modal:add)', '/nvmeof/(modal:add)')); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(1); + expect(getSubsystemSpy).toHaveBeenCalledTimes(1); + + routerEvents$.next(new NavigationEnd(2, '/nvmeof/subsystem', '/nvmeof/subsystem')); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(2); + expect(getSubsystemSpy).toHaveBeenCalledTimes(2); + }); + + it('should filter ALLOW_ALL_HOST from array response', fakeAsync(() => { + spyOn(nvmeofService, 'getInitiators').and.returnValue( + of([ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false } + ]) + ); + + component.listInitiators(); + tick(); + + expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }]); + })); + + it('should support hosts-wrapper response from getInitiators', fakeAsync(() => { + spyOn(nvmeofService, 'getInitiators').and.returnValue( + of({ + hosts: [ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true } + ] + }) + ); + + component.listInitiators(); + tick(); + + expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }]); + })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts index 4aa5fccb460..7f4461e96e0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts @@ -1,5 +1,7 @@ -import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; @@ -28,7 +30,7 @@ import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/n styleUrls: ['./nvmeof-initiators-list.component.scss'], standalone: false }) -export class NvmeofInitiatorsListComponent implements OnInit { +export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy { @Input() subsystemNQN: string; @Input() @@ -50,6 +52,9 @@ export class NvmeofInitiatorsListComponent implements OnInit { allowAllHost = ALLOW_ALL_HOST; yesLabel = $localize`Yes`; noLabel = $localize`No`; + allowAllHosts = false; + + private subscriptions = new Subscription(); constructor( public actionLabels: ActionLabelsI18n, @@ -78,9 +83,23 @@ export class NvmeofInitiatorsListComponent implements OnInit { this.fetchIfReady(); }); } else { + this.listInitiators(); this.getSubsystem(); } + this.subscriptions.add( + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => + event instanceof NavigationEnd && !event.urlAfterRedirects.includes('(modal:') + ) + ) + .subscribe(() => { + this.fetchIfReady(); + }) + ); + this.initiatorColumns = [ { name: $localize`Host NQN`, @@ -98,12 +117,9 @@ export class NvmeofInitiatorsListComponent implements OnInit { name: this.actionLabels.ADD, permission: 'create', icon: Icons.add, - click: () => - this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], { - queryParams: { group: this.group }, - relativeTo: this.route.parent - }), - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + click: () => this.openAddInitiatorForm(), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + disable: () => this.hasAllHostsAllowed() }, { name: $localize`Edit host key`, @@ -124,6 +140,10 @@ export class NvmeofInitiatorsListComponent implements OnInit { ]; } + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + private fetchIfReady() { if (this.subsystemNQN && this.group) { this.listInitiators(); @@ -131,15 +151,31 @@ export class NvmeofInitiatorsListComponent implements OnInit { } } + openAddInitiatorForm(disableAllowAll = false) { + this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], { + queryParams: { group: this.group }, + state: { disableAllowAll }, + relativeTo: this.route.parent + }); + } + editHostKeyModal() { const selected = this.selection.selected[0]; if (!selected) return; - this.modalService.show(NvmeofEditHostKeyModalComponent, { + const modalRef = this.modalService.show(NvmeofEditHostKeyModalComponent, { subsystemNQN: this.subsystemNQN, hostNQN: selected.nqn, group: this.group, dhchapKey: selected.dhchap_key || '' }); + if (modalRef?.closeChange) { + this.subscriptions.add( + modalRef.closeChange.subscribe(() => { + this.listInitiators(); + this.getSubsystem(); + }) + ); + } } getAllowAllHostIndex() { @@ -147,7 +183,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { } hasAllHostsAllowed(): boolean { - return this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST); + return !!this.subsystem?.allow_any_host && this.initiators.length === 0; } updateSelection(selection: CdTableSelection) { @@ -159,19 +195,22 @@ export class NvmeofInitiatorsListComponent implements OnInit { .getInitiators(this.subsystemNQN, this.group) .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => { const initiators = Array.isArray(response) ? response : response?.hosts || []; - this.initiators = initiators; + this.initiators = initiators.filter((i) => i.nqn !== ALLOW_ALL_HOST); this.updateAuthStatus(); }); } getSubsystem() { - this.nvmeofService.getSubsystem(this.subsystemNQN, this.group).subscribe((subsystem: any) => { - this.subsystem = subsystem; - this.updateAuthStatus(); - }); + this.nvmeofService + .getSubsystem(this.subsystemNQN, this.group) + .subscribe((subsystem: NvmeofSubsystem) => { + this.subsystem = subsystem; + this.updateAuthStatus(); + }); } updateAuthStatus() { + this.allowAllHosts = this.hasAllHostsAllowed(); if (this.subsystem && this.initiators) { this.authStatus = getSubsystemAuthStatus(this.subsystem, this.initiators); } @@ -195,7 +234,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { itemNames = [...hostNQNs, $localize`Allow any host(*)`]; } const hostName = itemNames[0]; - this.modalService.show(DeleteConfirmationModalComponent, { + const deleteModalRef = this.modalService.show(DeleteConfirmationModalComponent, { itemDescription: $localize`host`, impact: DeletionImpact.high, itemNames, @@ -215,5 +254,13 @@ export class NvmeofInitiatorsListComponent implements OnInit { }) }) }); + if (deleteModalRef?.closeChange) { + this.subscriptions.add( + deleteModalRef.closeChange.subscribe(() => { + this.listInitiators(); + this.getSubsystem(); + }) + ); + } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts index 6ac05c63b69..a259edf2bc4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts @@ -31,8 +31,11 @@ const mockNamespaces = [ ]; class MockNvmeOfService { + gatewayGroupsResponse: any = [[{ id: 'g1' }]]; + namespacesResponse: any = { namespaces: mockNamespaces }; + listGatewayGroups() { - return of([[{ id: 'g1' }]]); + return of(this.gatewayGroupsResponse); } formatGwGroupsList(_response: any) { @@ -40,7 +43,7 @@ class MockNvmeOfService { } listNamespaces(_group?: string) { - return of({ namespaces: mockNamespaces }); + return of(this.namespacesResponse); } } @@ -61,6 +64,7 @@ describe('NvmeofNamespacesListComponent', () => { let fixture: ComponentFixture; let modalService: MockModalCdsService; + let nvmeofService: MockNvmeOfService; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -81,6 +85,7 @@ describe('NvmeofNamespacesListComponent', () => { component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436'; fixture.detectChanges(); modalService = TestBed.inject(ModalCdsService) as any; + nvmeofService = TestBed.inject(NvmeofService) as any; }); it('should create', () => { @@ -117,4 +122,47 @@ describe('NvmeofNamespacesListComponent', () => { expect(args.itemDescription).toBeDefined(); expect(typeof args.submitActionObservable).toBe('function'); }); + + it('should deduplicate namespaces by nsid and subsystem nqn', (done) => { + component.group = 'g1'; + nvmeofService.namespacesResponse = { + namespaces: [ + { nsid: 1, ns_subsystem_nqn: 'sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub2' } + ] + }; + + component.namespaces$.pipe(take(1)).subscribe((namespaces) => { + expect(namespaces).toEqual([ + { nsid: 1, ns_subsystem_nqn: 'sub1', unique_id: '1_sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub2', unique_id: '1_sub2' } + ]); + done(); + }); + + component.listNamespaces(); + }); + + it('should default to first group and keep default placeholder when groups exist', () => { + component.group = null; + component.gwGroups = [{ content: 'g1', selected: false }] as any; + + component.updateGroupSelectionState(); + + expect(component.group).toBe('g1'); + expect(component.gwGroupsEmpty).toBe(false); + expect(component.gwGroupPlaceholder).toBe('Enter group name'); + }); + + it('should set error placeholder and call preventDefault on group fetch failure', () => { + const preventDefault = jasmine.createSpy('preventDefault'); + + component.handleGatewayGroupsError({ preventDefault }); + + expect(component.gwGroups).toEqual([]); + expect(component.gwGroupsEmpty).toBe(true); + expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups'); + expect(preventDefault).toHaveBeenCalled(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html index a733709627f..b06f9548b76 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html @@ -23,6 +23,7 @@ orientation="vertical"> Allow all hosts ; formGroup: CdFormGroup; @@ -36,7 +37,6 @@ export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep { csvDropText: string = $localize`Drag and drop files here or click to upload`; NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/; NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - ALLOW_ALL_HOST = '*'; uploadedHosts = new Set(); constructor( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts index a3532ec1e12..3b061f2bf00 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts @@ -133,4 +133,39 @@ describe('NvmeofSubsystemsComponent', () => { it('should set first group as default initially', () => { expect(component.group).toBe(mockGroups[0][0].spec.group); }); + + it('should mark only current group as selected', () => { + component.group = 'foo'; + component.gwGroups = [{ content: 'default' }, { content: 'foo' }] as any; + + component.updateGroupSelectionState(); + + expect(component.gwGroups).toEqual([ + { content: 'default', selected: false }, + { content: 'foo', selected: true } + ]); + }); + + it('should clear selected group and refresh subsystem list', () => { + component.group = 'default'; + const getSubsystemsSpy = spyOn(component, 'getSubsystems'); + + component.onGroupClear(); + + expect(component.group).toBeNull(); + expect(getSubsystemsSpy).toHaveBeenCalled(); + }); + + it('should set error placeholder and prevent default on groups load error', () => { + const preventDefault = jasmine.createSpy('preventDefault'); + component.context = { error: jasmine.createSpy('error') } as any; + + component.handleGatewayGroupsError({ preventDefault }); + + expect(component.gwGroups).toEqual([]); + expect(component.gwGroupsEmpty).toBe(true); + expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups'); + expect(preventDefault).toHaveBeenCalled(); + expect(component.context.error).toHaveBeenCalled(); + }); }); From 720a3b7c3d35988e1b481f86e9562e7855e38e8c Mon Sep 17 00:00:00 2001 From: Ali Masarwa Date: Mon, 29 Jun 2026 14:09:04 +0300 Subject: [PATCH 467/596] rgw/posix: reading a detele marker returns -ENOENT Signed-off-by: Ali Masarwa --- src/rgw/driver/posix/rgw_sal_posix.cc | 11 +++++++++-- src/rgw/driver/posix/rgw_sal_posix.h | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/rgw/driver/posix/rgw_sal_posix.cc b/src/rgw/driver/posix/rgw_sal_posix.cc index a7c097f82fe..01ce60f7210 100644 --- a/src/rgw/driver/posix/rgw_sal_posix.cc +++ b/src/rgw/driver/posix/rgw_sal_posix.cc @@ -1540,7 +1540,14 @@ int VersionedDirectory::stat(const DoutPrefixProvider* dpp, bool force) } bufferlist bl; if (rgw::sal::get_attr(attrs, RGW_POSIX_ATTR_VERSION, bl)) { - // cur_version.reset(); + uint16_t flags = 0; + ceph::decode(flags, bl); + if (flags & rgw_bucket_dir_entry::FLAG_DELETE_MARKER) { + ldpp_dout(dpp, 0) << "ERROR: a delete marker, returning ENOENT " + << get_name() << dendl; + cur_version.reset(); + return -ENOENT; + } } } @@ -1683,7 +1690,7 @@ int VersionedDirectory::copy(const DoutPrefixProvider *dpp, optional_yield y, int VersionedDirectory::add_delete_marker(const DoutPrefixProvider* dpp, optional_yield y, std::unique_ptr& marker, - std::string& name) + const std::string &name) { // Create as temporary file first int ret = marker->create(dpp, /*existed=*/nullptr, /*temp_file=*/true); diff --git a/src/rgw/driver/posix/rgw_sal_posix.h b/src/rgw/driver/posix/rgw_sal_posix.h index b3caeade70e..b0656eaf2b9 100644 --- a/src/rgw/driver/posix/rgw_sal_posix.h +++ b/src/rgw/driver/posix/rgw_sal_posix.h @@ -337,7 +337,7 @@ public: std::string get_new_instance(); int remove_symlink(const DoutPrefixProvider *dpp, optional_yield y, std::string match = ""); int add_file(const DoutPrefixProvider *dpp, std::unique_ptr&& file, bool* existed = nullptr, bool temp_file = false); - int add_delete_marker(const DoutPrefixProvider* dpp, optional_yield y, std::unique_ptr& marker, std::string& name); + int add_delete_marker(const DoutPrefixProvider* dpp, optional_yield y, std::unique_ptr& marker, const std::string &name); FSEnt* get_cur_version_ent() { return cur_version.get(); }; int set_cur_version_ent(const DoutPrefixProvider *dpp, FSEnt* file); virtual std::unique_ptr clone_base() override { From 6182e67ea44159e54bdf8c3af6f7648b562bebdd Mon Sep 17 00:00:00 2001 From: pujashahu Date: Wed, 3 Jun 2026 00:05:45 +0530 Subject: [PATCH 468/596] mgr/dashboard: NVMe Onboarding Setup Cards Fixes: https://tracker.ceph.com/issues/77067 Signed-off-by: pujaoshahu --- .../src/app/ceph/block/block.module.ts | 7 +- ...nvmeof-gateway-group-filter.component.html | 22 ++ ...nvmeof-gateway-group-filter.component.scss | 0 ...eof-gateway-group-filter.component.spec.ts | 111 ++++++ .../nvmeof-gateway-group-filter.component.ts | 115 ++++++ .../nvmeof-gateway-group.component.html | 2 - .../nvmeof-gateway-group.component.spec.ts | 47 ++- .../nvmeof-gateway-group.component.ts | 50 ++- .../nvmeof-gateway.component.ts | 2 - .../nvmeof-namespaces-list.component.html | 27 +- .../nvmeof-namespaces-list.component.spec.ts | 17 +- .../nvmeof-namespaces-list.component.ts | 106 ++---- .../nvmeof-setup-cards.component.html | 57 +++ .../nvmeof-setup-cards.component.scss | 0 .../nvmeof-setup-cards.component.spec.ts | 160 +++++++++ .../nvmeof-setup-cards.component.ts | 53 +++ .../ceph/block/nvmeof-state.service.spec.ts | 187 ++++++++++ .../app/ceph/block/nvmeof-state.service.ts | 95 +++++ ...ubsystem-namespaces-list.component.spec.ts | 11 +- ...eof-subsystem-namespaces-list.component.ts | 31 +- .../nvmeof-subsystems.component.html | 26 +- .../nvmeof-subsystems.component.spec.ts | 125 ++++--- .../nvmeof-subsystems.component.ts | 95 +---- .../nvmeof-tabs/nvmeof-tabs.component.html | 76 ++-- .../nvmeof-tabs/nvmeof-tabs.component.spec.ts | 332 +++++++++++++++++- .../nvmeof-tabs/nvmeof-tabs.component.ts | 84 ++++- .../src/app/shared/api/nvmeof.service.ts | 64 +++- .../setup-step-card.component.html | 20 ++ .../setup-step-card.component.scss | 24 ++ .../setup-step-card.component.spec.ts | 134 +++++++ .../setup-step-card.component.ts | 33 ++ .../datatable/table/table.component.html | 15 +- 32 files changed, 1779 insertions(+), 349 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 3c5c8d9e7cc..135ec9134b4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -106,6 +106,8 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performance/nvmeof-subsystem-performance.component'; import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component'; +import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component'; +import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component'; @NgModule({ imports: [ @@ -142,7 +144,9 @@ import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-gr ContainedListModule, SideNavModule, LayoutModule, - ThemeModule + ThemeModule, + NvmeofSetupCardsComponent, + NvmeofGatewayGroupFilterComponent ], declarations: [ RbdListComponent, @@ -337,6 +341,7 @@ const routes: Routes = [ { path: 'nvmeof', canActivate: [ModuleStatusGuardService], + component: NvmeofTabsComponent, data: { breadcrumbs: true, text: 'NVMe/TCP', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html new file mode 100644 index 00000000000..d3b43ababa9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html @@ -0,0 +1,22 @@ +
+
+
+ + + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts new file mode 100644 index 00000000000..aef321896f5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter.component'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +const MOCK_GROUPS_RESPONSE = [ + [ + { spec: { group: 'grp1' }, service_name: 'nvmeof.grp1' }, + { spec: { group: 'grp2' }, service_name: 'nvmeof.grp2' } + ] +]; + +describe('NvmeofGatewayGroupFilterComponent', () => { + let fixture: ComponentFixture; + let component: NvmeofGatewayGroupFilterComponent; + let nvmeofService: Partial; + let routerSpy: Partial; + let activatedRoute: Partial; + + beforeEach(async () => { + nvmeofService = { + listGatewayGroups: jest.fn().mockReturnValue(of(MOCK_GROUPS_RESPONSE)), + formatGwGroupsList: jest.fn().mockReturnValue([{ content: 'grp1' }, { content: 'grp2' }]) + }; + + routerSpy = { + navigate: jest.fn().mockResolvedValue(true) + }; + + activatedRoute = { + snapshot: { + queryParams: {} + } as any + }; + + await TestBed.configureTestingModule({ + imports: [NvmeofGatewayGroupFilterComponent], + providers: [ + { provide: NvmeofService, useValue: nvmeofService }, + { provide: Router, useValue: routerSpy }, + { provide: ActivatedRoute, useValue: activatedRoute } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofGatewayGroupFilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should load gateway groups on init', () => { + fixture.detectChanges(); + expect(nvmeofService.listGatewayGroups).toHaveBeenCalled(); + expect(component.items.length).toBe(2); + }); + + it('should default to the first group when no group is in the URL', () => { + fixture.detectChanges(); + expect(routerSpy.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { group: 'grp1' } }) + ); + }); + + it('should mark the URL group as selected when a group is already in the URL', () => { + (activatedRoute as any).snapshot.queryParams = { group: 'grp2' }; + fixture.detectChanges(); + const selected = component.items.find((i) => i.selected); + expect(selected?.content).toBe('grp2'); + }); + + it('should emit groupChange when a group is selected', () => { + fixture.detectChanges(); + const emitSpy = jest.spyOn(component.groupChange, 'emit'); + component.onSelected({ content: 'grp2' }); + expect(emitSpy).toHaveBeenCalledWith('grp2'); + }); + + it('should emit null groupChange when cleared', () => { + fixture.detectChanges(); + const emitSpy = jest.spyOn(component.groupChange, 'emit'); + component.onClear(); + expect(emitSpy).toHaveBeenCalledWith(null); + }); + + it('should disable and set placeholder when no groups are available', () => { + (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(of([[]])); + fixture.detectChanges(); + expect(component.disabled).toBe(true); + expect(component.placeholder).toBe('No groups available'); + }); + + it('should disable and set error placeholder on API error', () => { + const err = new Error('network error'); + (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(() => err)); + fixture.detectChanges(); + expect(component.disabled).toBe(true); + expect(component.placeholder).toBe('Unable to fetch Gateway groups'); + }); + + it('should render a cds-combo-box', () => { + fixture.detectChanges(); + const combo = fixture.nativeElement.querySelector('cds-combo-box'); + expect(combo).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts new file mode 100644 index 00000000000..39dd35faa3a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts @@ -0,0 +1,115 @@ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { ComboBoxModule, GridModule, LayoutModule } from 'carbon-components-angular'; +import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { CephServiceSpec } from '~/app/shared/models/service.interface'; + +const DEFAULT_PLACEHOLDER = $localize`Enter group name`; + +@Component({ + selector: 'cd-nvmeof-gateway-group-filter', + templateUrl: './nvmeof-gateway-group-filter.component.html', + styleUrls: ['./nvmeof-gateway-group-filter.component.scss'], + standalone: true, + imports: [ComboBoxModule, GridModule, LayoutModule, RouterModule] +}) +export class NvmeofGatewayGroupFilterComponent implements OnInit, OnDestroy { + @Output() groupChange = new EventEmitter(); + + items: GroupsComboboxItem[] = []; + disabled = false; + placeholder = DEFAULT_PLACEHOLDER; + + private destroy$ = new Subject(); + + constructor( + private nvmeofService: NvmeofService, + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit(): void { + this.loadGatewayGroups(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSelected(item: GroupsComboboxItem): void { + this.syncQueryParam(item.content); + } + + onClear(): void { + this.syncQueryParam(null); + } + + private loadGatewayGroups(): void { + this.nvmeofService + .listGatewayGroups() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: CephServiceSpec[][]) => this.onGroupsLoaded(response), + error: (error: any) => this.onGroupsError(error) + }); + } + + private onGroupsLoaded(response: CephServiceSpec[][]): void { + if (response?.[0]?.length) { + this.items = this.nvmeofService.formatGwGroupsList(response); + } else { + this.items = []; + } + this.syncSelectionState(); + } + + private onGroupsError(error: any): void { + this.items = []; + this.disabled = true; + this.placeholder = $localize`Unable to fetch Gateway groups`; + if (error?.preventDefault) { + error.preventDefault(); + } + this.groupChange.emit(null); + } + + private syncSelectionState(): void { + if (this.items.length) { + this.disabled = false; + this.placeholder = DEFAULT_PLACEHOLDER; + const urlGroup = this.route.snapshot.queryParams['group']?.trim() || null; + if (!urlGroup) { + this.syncQueryParam(this.items[0].content); + } else { + this.items = this.items.map((g) => ({ ...g, selected: g.content === urlGroup })); + this.groupChange.emit(urlGroup); + } + } else { + this.disabled = true; + this.placeholder = $localize`No groups available`; + this.groupChange.emit(null); + } + } + + private syncQueryParam(group: string | null): void { + const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null; + if (currentGroup === group) { + // URL already correct — still emit so the parent gets the current value on init + this.items = this.items.map((g) => ({ ...g, selected: g.content === group })); + this.groupChange.emit(group); + return; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams: { group: group || null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + this.items = this.items.map((g) => ({ ...g, selected: g.content === group })); + this.groupChange.emit(group); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html index eae963ce641..40ffb638bfa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html @@ -1,5 +1,3 @@ - - { let component: NvmeofGatewayGroupComponent; @@ -22,6 +24,11 @@ describe('NvmeofGatewayGroupComponent', () => { listSubsystems: jest.fn().mockReturnValue(of([])) }; + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ imports: [HttpClientModule, SharedModule, TabsModule, GridModule, ModalModule], declarations: [NvmeofGatewayGroupComponent], @@ -34,7 +41,8 @@ describe('NvmeofGatewayGroupComponent', () => { { provide: ModalCdsService, useValue: { show: jest.fn() } - } + }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -298,4 +306,39 @@ describe('NvmeofGatewayGroupComponent', () => { ); }); }); + + it('should refresh table and setup state after gateway group delete completes', () => { + const modalService = TestBed.inject(ModalCdsService); + const taskWrapperService = TestBed.inject(TaskWrapperService); + const nvmeofStateService = TestBed.inject(NvmeofStateService); + + jest.spyOn(modalService, 'show').mockImplementation(() => undefined); + jest.spyOn(taskWrapperService, 'wrapTaskAroundCall').mockReturnValue( + new Observable((observer) => { + observer.complete(); + }) + ); + + const refreshBtnSpy = jest.fn(); + component.table = { refreshBtn: refreshBtnSpy } as any; + const requestRefreshSpy = jest.spyOn(nvmeofStateService, 'requestRefresh'); + + component.selection = { + first: () => ({ + service_name: 'nvmeof.rbd.default', + spec: { group: 'default' }, + subSystemCount: 0 + }), + hasSelection: true + } as any; + + component.deleteGatewayGroupModal(); + + const submitActionObservable = (modalService.show as jest.Mock).mock.calls[0][1] + .submitActionObservable; + submitActionObservable().subscribe(); + + expect(refreshBtnSpy).toHaveBeenCalled(); + expect(requestRefreshSpy).toHaveBeenCalled(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index 933a085a1ff..5d622a0bdef 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -1,7 +1,14 @@ -import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; +import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators'; import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { HostService } from '~/app/shared/api/host.service'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; @@ -25,6 +32,7 @@ import { NotificationService } from '~/app/shared/services/notification.service' import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component'; +import { NvmeofStateService } from '../nvmeof-state.service'; const BASE_URL = 'block/nvmeof/gateways'; @@ -36,7 +44,9 @@ const BASE_URL = 'block/nvmeof/gateways'; encapsulation: ViewEncapsulation.None, providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) -export class NvmeofGatewayGroupComponent implements OnInit { +export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + @ViewChild(TableComponent, { static: true }) table: TableComponent; @@ -63,6 +73,7 @@ export class NvmeofGatewayGroupComponent implements OnInit { gatewayGroupName: string; subsystemCount: number; gatewayCount: number; + private lastGroupCount = 0; viewUrl = `/${BASE_URL}/view`; icons = Icons; @@ -79,7 +90,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { public taskWrapper: TaskWrapperService, private notificationService: NotificationService, private urlBuilder: URLBuilderService, - private router: Router + private router: Router, + private nvmeofStateService: NvmeofStateService ) {} ngOnInit(): void { @@ -172,9 +184,20 @@ export class NvmeofGatewayGroupComponent implements OnInit { return of([]); }) ) - ) + ), + shareReplay({ bufferSize: 1, refCount: true }), + tap((groups) => { + const wasNonEmpty = this.lastGroupCount > 0; + this.lastGroupCount = groups.length; + if (wasNonEmpty && groups.length === 0) { + this.nvmeofStateService.requestRefresh(); + } + }) ); this.checkNodesAvailability(); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); } fetchData(): void { this.subject.next([]); @@ -240,17 +263,19 @@ export class NvmeofGatewayGroupComponent implements OnInit { call: this.cephServiceService.delete(serviceName) }) .pipe( - map(() => { - this.table.refreshBtn(); + tap({ + complete: () => { + this.nvmeofStateService.requestRefresh(); + } }), catchError((error) => { - this.table.refreshBtn(); this.notificationService.show( NotificationType.error, $localize`${`Failed to delete gateway group ${selectedGroup.spec.group}: ${error.message}`}` ); return of(null); - }) + }), + finalize(() => this.table?.refreshBtn()) ); } }); @@ -299,4 +324,9 @@ export class NvmeofGatewayGroupComponent implements OnInit { } this.router.navigate([this.viewUrl, groupName]); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts index 88d2d4f5fd5..3223e7a1c41 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts @@ -44,8 +44,6 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy { this.route.queryParams.subscribe((params) => { if (params['tab'] && Object.values(TABS).includes(params['tab'])) { this.activeTab = params['tab'] as TABS; - } else { - this.activeTab = TABS.gateways; } this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html index 79c82c3c4a2..38c5c2fc78c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html @@ -1,27 +1,6 @@ - - -
-
-
- - - -
-
-
+ + { let modalService: MockModalCdsService; beforeEach(async () => { + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent], imports: [HttpClientModule, RouterTestingModule, SharedModule], @@ -70,9 +76,10 @@ describe('NvmeofNamespacesListComponent', () => { { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, { provide: ModalCdsService, useClass: MockModalCdsService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(NvmeofNamespacesListComponent); @@ -98,7 +105,7 @@ describe('NvmeofNamespacesListComponent', () => { ); done(); }); - component.listNamespaces(); + component.fetchData(); }); it('should open delete modal with correct data', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts index 37b1aa4d3a6..3217bc23cd8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts @@ -1,6 +1,6 @@ import { Component, Input, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; @@ -11,16 +11,14 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof'; import { Permission } from '~/app/shared/models/permissions'; -import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; -import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; - -const DEFAULT_PLACEHOLDER = $localize`Enter group name`; +import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; @Component({ selector: 'cd-nvmeof-namespaces-list', @@ -42,11 +40,6 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { namespaces$: Observable; private namespaceSubject = new BehaviorSubject(undefined); - // Gateway group dropdown properties - gwGroups: GroupsComboboxItem[] = []; - gwGroupsEmpty: boolean = false; - gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; - private destroy$ = new Subject(); constructor( @@ -58,16 +51,13 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { private authStorageService: AuthStorageService, private taskWrapper: TaskWrapperService, private nvmeofService: NvmeofService, - private dimlessBinaryPipe: DimlessBinaryPipe + private dimlessBinaryPipe: DimlessBinaryPipe, + private nvmeofStateService: NvmeofStateService ) { this.permission = this.authStorageService.getPermissions().nvmeof; } ngOnInit() { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); - }); - this.setGatewayGroups(); this.namespacesColumns = [ { name: $localize`Namespace ID`, @@ -166,78 +156,24 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { }), takeUntil(this.destroy$) ); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); + } + + onGroupChange(group: string | null): void { + this.group = group; + this.namespaceSubject.next(); } updateSelection(selection: CdTableSelection) { this.selection = selection; } - listNamespaces() { - this.namespaceSubject.next(); - } - fetchData() { this.namespaceSubject.next(); } - // Gateway groups methods - onGroupSelection(selected: GroupsComboboxItem) { - selected.selected = true; - this.group = selected.content; - this.listNamespaces(); - } - - onGroupClear() { - this.group = null; - this.listNamespaces(); - } - - setGatewayGroups() { - this.nvmeofService - .listGatewayGroups() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response), - error: (error) => this.handleGatewayGroupsError(error) - }); - } - - handleGatewayGroupsSuccess(response: CephServiceSpec[][]) { - if (response?.[0]?.length) { - this.gwGroups = this.nvmeofService.formatGwGroupsList(response); - } else { - this.gwGroups = []; - } - this.updateGroupSelectionState(); - } - - updateGroupSelectionState() { - if (this.gwGroups.length) { - if (!this.group) { - this.onGroupSelection(this.gwGroups[0]); - } else { - this.gwGroups = this.gwGroups.map((g) => ({ - ...g, - selected: g.content === this.group - })); - } - this.gwGroupsEmpty = false; - this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; - } else { - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`No groups available`; - } - } - - handleGatewayGroupsError(error: any) { - this.gwGroups = []; - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`; - if (error?.preventDefault) { - error?.preventDefault?.(); - } - } - deleteNamespaceModal() { const namespace = this.selection.first(); const subsystemNqn = namespace.ns_subsystem_nqn; @@ -251,13 +187,15 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { deletionMessage: $localize`Deleting the namespace ${namespace.nsid} will permanently remove all resources, services, and configurations within it. This action cannot be undone.` }, submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/namespace/delete', { - nqn: subsystemNqn, - nsid: namespace.nsid - }), - call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/delete', { + nqn: subsystemNqn, + nsid: namespace.nsid + }), + call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group) + }) + .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() })) }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html new file mode 100644 index 00000000000..d4fb4f1b491 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html @@ -0,0 +1,57 @@ + + +
+

Recommended first-time setup

+

+ Start your NVMe over Fabrics configuration by creating the essential resources in sequence. +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (isAllConfigured) { + + } +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts new file mode 100644 index 00000000000..a41e9e24f0e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts @@ -0,0 +1,160 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; + +import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards.component'; + +describe('NvmeofSetupCardsComponent', () => { + let component: NvmeofSetupCardsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NvmeofSetupCardsComponent, RouterModule.forRoot([])] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSetupCardsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render 3 setup step cards', () => { + const cards = fixture.nativeElement.querySelectorAll('cd-setup-step-card'); + expect(cards.length).toBe(3); + }); + + it('should not show completion link when isAllConfigured is false', () => { + component.isAllConfigured = false; + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion'); + expect(link).toBeNull(); + }); + + it('should show completion link when isAllConfigured is true', () => { + component.isAllConfigured = true; + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion'); + expect(link).toBeTruthy(); + }); + + describe('setup state', () => { + const getStepCards = () => + fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + it('should show gateway-only setup state when gateway exists but subsystems and namespaces do not', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cards = getStepCards(); + expect(cards[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cards[1].componentInstance.statusMessage).toBe( + 'No subsystem configured for this cluster yet.' + ); + expect(cards[2].componentInstance.statusMessage).toBe( + 'No namespace allocated or mapped yet.' + ); + }); + + it('should show subsystem-complete setup state when gateway and subsystem exist', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cards = getStepCards(); + expect(cards[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cards[1].componentInstance.statusMessage).toBe('Subsystem configured successfully.'); + expect(cards[2].componentInstance.statusMessage).toBe( + 'No namespace allocated or mapped yet.' + ); + }); + }); + + it('should display "No gateway configured yet." on step 2 and 3 when hasGatewayGroups is false', () => { + component.hasGatewayGroups = false; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + const subsystemCard = cardElements[1].componentInstance; + const namespaceCard = cardElements[2].componentInstance; + + expect(subsystemCard.statusMessage).toBe('No gateway configured yet.'); + expect(namespaceCard.statusMessage).toBe('No gateway configured yet.'); + }); + + it('should display original info messages when hasGatewayGroups is true', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + const subsystemCard = cardElements[1].componentInstance; + const namespaceCard = cardElements[2].componentInstance; + + expect(subsystemCard.statusMessage).toBe('No subsystem configured for this cluster yet.'); + expect(namespaceCard.statusMessage).toBe('No namespace allocated or mapped yet.'); + }); + + it('should display gateway info message when hasGatewayGroups is false', () => { + component.hasGatewayGroups = false; + fixture.detectChanges(); + + const gatewayCard = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card')[0] + .componentInstance; + + expect(gatewayCard.statusMessage).toBe('No gateway groups configured for this cluster yet.'); + }); + + it('should display success messages when all setup steps are configured', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = true; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cardElements[1].componentInstance.statusMessage).toBe( + 'Subsystem configured successfully.' + ); + expect(cardElements[2].componentInstance.statusMessage).toBe('Namespaces mapped successfully.'); + }); + + it('should update all step messages when gateway groups are removed after full configuration', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = true; + fixture.detectChanges(); + + const getCards = () => fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(getCards()[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + + component.hasGatewayGroups = false; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + expect(getCards()[0].componentInstance.statusMessage).toBe( + 'No gateway groups configured for this cluster yet.' + ); + expect(getCards()[1].componentInstance.statusMessage).toBe('No gateway configured yet.'); + expect(getCards()[2].componentInstance.statusMessage).toBe('No gateway configured yet.'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts new file mode 100644 index 00000000000..9dc9100fa90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, ViewEncapsulation } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { LayoutModule, LayerModule, LinkModule, TilesModule } from 'carbon-components-angular'; +import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component'; +import { SetupStepCardComponent } from '~/app/shared/components/setup-step-card/setup-step-card.component'; + +@Component({ + selector: 'cd-nvmeof-setup-cards', + templateUrl: './nvmeof-setup-cards.component.html', + styleUrls: ['./nvmeof-setup-cards.component.scss'], + standalone: true, + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + RouterModule, + LayoutModule, + LayerModule, + TilesModule, + LinkModule, + ProductiveCardComponent, + SetupStepCardComponent + ] +}) +export class NvmeofSetupCardsComponent { + @Input() hasGatewayGroups = false; + @Input() hasSubsystems = false; + @Input() hasNamespaces = false; + @Input() isAllConfigured = false; + + readonly gatewayPendingMessage = $localize`No gateway configured yet.`; + + readonly cards = { + gateway: { + title: $localize`Create Gateway groups`, + description: $localize`Group NVMe gateway nodes to enable high availability and load balancing for storage targets.`, + successMessage: $localize`Gateway group configured successfully.`, + infoMessage: $localize`No gateway groups configured for this cluster yet.` + }, + subsystem: { + title: $localize`Create Subsystems`, + description: $localize`Define storage targets by creating NVMe subsystems and configuring security, listeners, and host access.`, + successMessage: $localize`Subsystem configured successfully.`, + infoMessage: $localize`No subsystem configured for this cluster yet.` + }, + namespace: { + title: $localize`Create Namespaces`, + description: $localize`Create storage namespaces backed by Ceph block images. This completes your NVMe over Fabrics setup.`, + successMessage: $localize`Namespaces mapped successfully.`, + infoMessage: $localize`No namespace allocated or mapped yet.` + } + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts new file mode 100644 index 00000000000..ccdd04dcd25 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from '@angular/core/testing'; + +import { Subject } from 'rxjs'; + +import { NvmeofStateService } from './nvmeof-state.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +describe('NvmeofStateService', () => { + let service: NvmeofStateService; + let summaryData$: Subject; + let summaryServiceSpy: any; + + const emitSummary = (tasks: any[]) => summaryData$.next({ finished_tasks: tasks }); + + beforeEach(() => { + summaryData$ = new Subject(); + summaryServiceSpy = { + subscribe: jest.fn().mockImplementation((callback: (s: any) => void) => { + return summaryData$.subscribe(callback); + }) + }; + + TestBed.configureTestingModule({ + providers: [NvmeofStateService, { provide: SummaryService, useValue: summaryServiceSpy }] + }); + + service = TestBed.inject(NvmeofStateService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + it('should emit refresh$ when requestRefresh is called', (done) => { + service.refresh$.subscribe(() => done()); + service.requestRefresh(); + }); + + it('should not emit refresh$ on the first summary update (initialization baseline)', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should emit refresh$ when a new NVMe service/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a new NVMe service/create task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/create', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/subsystem/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/subsystem/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/namespace/create task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/namespace/create', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/namespace/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/namespace/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nsid: 1, nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should not emit refresh$ for non-NVMe tasks', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([{ name: 'rbd/create', begin_time: '2026-01-01T00:00:00Z', metadata: {} }]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should not emit refresh$ for service/delete of a non-NVMe service', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'rbd.default' } + } + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should not emit refresh$ for the same task appearing again', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + const task = { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + }; + + emitSummary([]); // init + emitSummary([task]); // new task — emits once + emitSummary([task]); // same task — no second emit + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe from SummaryService on destroy', () => { + const unsubscribeSpy = jest.fn(); + const mockSubscription = { unsubscribe: unsubscribeSpy }; + summaryServiceSpy.subscribe.mockReturnValueOnce(mockSubscription); + + const freshService = new NvmeofStateService(summaryServiceSpy as any); + freshService.ngOnDestroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts new file mode 100644 index 00000000000..c8343a29f9b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts @@ -0,0 +1,95 @@ +import { Injectable, OnDestroy } from '@angular/core'; + +import { merge, Subject, Subscription } from 'rxjs'; + +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { Summary } from '~/app/shared/models/summary.model'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +/** + * Provides a unified refresh$ stream for NVMe-oF UI components to react to + * task completions and explicit refresh requests. + * + * A dedicated service is used instead of reusing TaskListService because + * TaskListService is tightly coupled to the task-list UI component and does + * not expose a clean observable suitable for driving side-effects such as + * setup-card state reloads. + * + * This service is provided at the NvmeofTabsComponent level to ensure + * subscriptions are properly destroyed when navigating away from nvmeof pages. + */ +@Injectable() +export class NvmeofStateService implements OnDestroy { + private readonly explicitRefreshSource = new Subject(); + private readonly taskRefreshSource = new Subject(); + private summarySubscription: Subscription; + private seenTaskSignatures = new Set(); + private summaryInitialized = false; + + readonly refresh$ = merge( + this.explicitRefreshSource.asObservable(), + this.taskRefreshSource.asObservable() + ); + + constructor(private summaryService: SummaryService) { + this.summarySubscription = this.summaryService.subscribe((summary: Summary) => { + const nvmeTasks = (summary?.finished_tasks ?? []).filter((task: FinishedTask) => + this.isNvmeTask(task) + ); + const currentSignatures = new Set( + nvmeTasks.map((task: FinishedTask) => this.getTaskSignature(task)) + ); + + if (!this.summaryInitialized) { + this.summaryInitialized = true; + this.seenTaskSignatures = currentSignatures; + return; + } + + const hasNewTask = [...currentSignatures].some( + (taskSignature) => !this.seenTaskSignatures.has(taskSignature) + ); + this.seenTaskSignatures = currentSignatures; + + if (hasNewTask) { + this.taskRefreshSource.next(); + } + }); + } + + ngOnDestroy(): void { + this.summarySubscription?.unsubscribe(); + } + + requestRefresh(): void { + this.explicitRefreshSource.next(); + } + + private isNvmeTask(task: FinishedTask): boolean { + if (!task?.name) { + return false; + } + + if (task.name === 'service/create' || task.name === 'service/delete') { + const serviceName = task.metadata?.['service_name']; + return typeof serviceName === 'string' && serviceName.startsWith('nvmeof.'); + } + + return [ + 'nvmeof/gateway/create', + 'nvmeof/gateway/delete', + 'nvmeof/subsystem/create', + 'nvmeof/subsystem/delete', + 'nvmeof/namespace/create', + 'nvmeof/namespace/delete' + ].includes(task.name); + } + + private getTaskSignature(task: FinishedTask): string { + return JSON.stringify({ + name: task.name, + begin_time: task.begin_time, + metadata: task.metadata + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts index 68927708547..57fa67a8b89 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts @@ -2,10 +2,11 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { ActivatedRoute } from '@angular/router'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { SharedModule } from '~/app/shared/shared.module'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; @@ -42,6 +43,11 @@ describe('NvmeofSubsystemNamespacesListComponent', () => { } beforeEach(async () => { + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemNamespacesListComponent], imports: [HttpClientTestingModule, RouterTestingModule, SharedModule], @@ -61,7 +67,8 @@ describe('NvmeofSubsystemNamespacesListComponent', () => { listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces)) } }, - { provide: AuthStorageService, useClass: MockAuthStorageService } + { provide: AuthStorageService, useClass: MockAuthStorageService }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ] }).compileComponents(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts index 852f0a006d8..fe8e6591d66 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts @@ -14,8 +14,9 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { combineLatest, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { catchError, takeUntil, tap } from 'rxjs/operators'; @Component({ selector: 'cd-nvmeof-subsystem-namespaces-list', @@ -35,7 +36,6 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy private destroy$ = new Subject(); constructor( - // ... constructor stays mostly same public actionLabels: ActionLabelsI18n, private router: Router, private modalService: ModalCdsService, @@ -44,7 +44,8 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy private nvmeofService: NvmeofService, private dimlessBinaryPipe: DimlessBinaryPipe, private iopsPipe: IopsPipe, - private route: ActivatedRoute + private route: ActivatedRoute, + private nvmeofStateService: NvmeofStateService ) { this.permission = this.authStorageService.getPermissions().nvmeof; } @@ -155,7 +156,13 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy if (this.group) { this.nvmeofService .listNamespaces(this.group, this.subsystemNQN) - .pipe(takeUntil(this.destroy$)) + .pipe( + catchError(() => { + this.namespaces = []; + return []; + }), + takeUntil(this.destroy$) + ) .subscribe((res: NvmeofSubsystemNamespace[]) => { this.namespaces = res || []; }); @@ -171,13 +178,15 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy itemNames: [namespace.nsid], actionDescription: 'delete', submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/namespace/delete', { - nqn: this.subsystemNQN, - nsid: namespace.nsid - }), - call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/delete', { + nqn: this.subsystemNQN, + nsid: namespace.nsid + }), + call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group) + }) + .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() })) }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html index 3111ae2e8d4..aef098c0d86 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html @@ -1,27 +1,7 @@ - + + -
-
-
- - - -
-
-
({ + content: g.spec.group, + selected: false + })); + } + listSubsystems() { return of(mockSubsystems); } @@ -66,14 +58,6 @@ class MockNvmeOfService { getInitiators() { return of([]); } - - formatGwGroupsList(_data: CephServiceSpec[][]) { - return mockformattedGwGroups; - } - - listGatewayGroups() { - return of(mockGroups); - } } class MockAuthStorageService { @@ -89,21 +73,55 @@ class MockTaskWrapperService {} describe('NvmeofSubsystemsComponent', () => { let component: NvmeofSubsystemsComponent; let fixture: ComponentFixture; + let nvmeofService: MockNvmeOfService; + let queryParams$: BehaviorSubject>; + const activatedRouteMock = { + queryParams: null as any, + snapshot: { queryParams: {} as Record } + }; beforeEach(async () => { + queryParams$ = new BehaviorSubject>({}); + activatedRouteMock.queryParams = queryParams$.asObservable(); + activatedRouteMock.snapshot.queryParams = queryParams$.value; + + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemsComponent, NvmeofSubsystemsDetailsComponent], - imports: [HttpClientModule, RouterTestingModule, SharedModule, ComboBoxModule, GridModule], + imports: [ + HttpClientModule, + RouterTestingModule, + SharedModule, + ComboBoxModule, + GridModule, + NvmeofGatewayGroupFilterComponent + ], providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, { provide: ModalCdsService, useClass: MockModalService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ] }).compileComponents(); + const router = TestBed.inject(Router); + jest.spyOn(router, 'navigate').mockImplementation((_commands, extras?) => { + const group = extras?.queryParams?.['group']; + const params = group ? { group: String(group) } : {}; + activatedRouteMock.snapshot.queryParams = params; + queryParams$.next(params); + return Promise.resolve(true); + }); + fixture = TestBed.createComponent(NvmeofSubsystemsComponent); component = fixture.componentInstance; + nvmeofService = TestBed.inject(NvmeofService) as any; component.ngOnInit(); fixture.detectChanges(); }); @@ -115,22 +133,31 @@ describe('NvmeofSubsystemsComponent', () => { it('should retrieve subsystems', (done) => { const expected = mockSubsystems.map((s) => ({ ...s, - gw_group: component.group, + gw_group: 'default', auth: 'No authentication', initiator_count: 0 })); - component.subsystems$.subscribe((subsystems) => { + component.onGroupChange('default'); + component.subsystems$.pipe(skip(1), take(1)).subscribe((subsystems) => { expect(subsystems).toEqual(expected); done(); }); - component.getSubsystems(); + component.fetchData(); }); - it('should load gateway groups correctly', () => { - expect(component.gwGroups.length).toBe(2); + it('should not fetch subsystems when group is not selected', (done) => { + const listSubsystemsSpy = jest.spyOn(nvmeofService, 'listSubsystems'); + component.group = null; + component.fetchData(); + + component.subsystems$.pipe(take(1)).subscribe((subsystems) => { + expect(subsystems).toEqual([]); + expect(listSubsystemsSpy).not.toHaveBeenCalled(); + done(); + }); }); it('should set first group as default initially', () => { - expect(component.group).toBe(mockGroups[0][0].spec.group); + expect(component.group).toBe('default'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts index 85590b314b5..cc2467f0652 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { @@ -18,15 +18,14 @@ import { Icons } from '~/app/shared/enum/icons.enum'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; -import { CephServiceSpec } from '~/app/shared/models/service.interface'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; const BASE_URL = 'block/nvmeof/subsystems'; -const DEFAULT_PLACEHOLDER = $localize`Enter group name`; @Component({ selector: 'cd-nvmeof-subsystems', @@ -55,10 +54,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit tableActions: CdTableAction[]; subsystemDetails: any[]; context: CdTableFetchDataContext; - gwGroups: GroupsComboboxItem[] = []; group: string = null; - gwGroupsEmpty: boolean = false; - gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; authType = NvmeofSubsystemAuthType; subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>; private subsystemSubject = new BehaviorSubject(undefined); @@ -70,19 +66,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, private router: Router, - private route: ActivatedRoute, private modalService: ModalCdsService, - private taskWrapper: TaskWrapperService + private taskWrapper: TaskWrapperService, + private nvmeofStateService: NvmeofStateService ) { super(); this.permissions = this.authStorageService.getPermissions(); } ngOnInit() { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); - }); - this.setGatewayGroups(); this.subsystemsColumns = [ { name: $localize`Subsystem NQN`, @@ -152,16 +144,20 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit }), takeUntil(this.destroy$) ); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); + } + + onGroupChange(group: string | null): void { + this.group = group; + this.subsystemSubject.next(); } updateSelection(selection: CdTableSelection) { this.selection = selection; } - getSubsystems() { - this.subsystemSubject.next(); - } - fetchData() { this.subsystemSubject.next(); } @@ -179,68 +175,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit forceDeleteAcknowledgementMessage: $localize`I understand this may remove resources still attached to this subsystem.` }, submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }), - call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }), + call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group) + }) + .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() })) }); } - onGroupSelection(selected: GroupsComboboxItem) { - selected.selected = true; - this.group = selected.content; - this.getSubsystems(); - } - - onGroupClear() { - this.group = null; - this.getSubsystems(); - } - - setGatewayGroups() { - this.nvmeofService - .listGatewayGroups() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response), - error: (error) => this.handleGatewayGroupsError(error) - }); - } - - handleGatewayGroupsSuccess(response: CephServiceSpec[][]) { - if (response?.[0]?.length) { - this.gwGroups = this.nvmeofService.formatGwGroupsList(response); - } else { - this.gwGroups = []; - } - this.updateGroupSelectionState(); - } - - updateGroupSelectionState() { - if (this.gwGroups.length) { - this.gwGroupsEmpty = false; - this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; - if (!this.group) { - this.onGroupSelection(this.gwGroups[0]); - } else { - this.gwGroups = this.gwGroups.map((g) => ({ - ...g, - selected: g.content === this.group - })); - } - } else { - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`No groups available`; - } - } - - handleGatewayGroupsError(error: any) { - this.gwGroups = []; - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`; - this.handleError(error); - } - private handleError(error: any): void { if (error?.preventDefault) { error?.preventDefault?.(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html index 9b3388838d5..a4f9f1e5241 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html @@ -1,31 +1,45 @@ -
- -

NVMe over Fabrics (TCP)

- Monitor and manage NVMe-over-TCP resources for high-performance block storage. -
-
-
- - - - - - - - -
+@if (showTabsShell) { +
+ +

NVMe over Fabrics (TCP)

+

Monitor and manage NVMe-over-TCP resources for high-performance block storage.

+
+
+ + @if (showSetupCards) { + + + } + +
+ + + + + + + + +
+} + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts index cdd1ea2e9cf..b97da033c23 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts @@ -1,26 +1,79 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Event as RouterEvent, NavigationEnd, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { BehaviorSubject, Subject, of } from 'rxjs'; import { TabsModule } from 'carbon-components-angular'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { NvmeofTabsComponent } from './nvmeof-tabs.component'; import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofSetupCardsComponent } from '../nvmeof-setup-cards/nvmeof-setup-cards.component'; + +type SetupState = { + hasGatewayGroups: boolean; + hasSubsystems: boolean; + hasNamespaces: boolean; +}; describe('NvmeofTabsComponent', () => { let component: NvmeofTabsComponent; let fixture: ComponentFixture; let router: Router; + let nvmeofServiceSpy: any; + let queryParams$: BehaviorSubject; + let refresh$: Subject; + let routerEvents$: Subject; + let currentSetupState: SetupState; + + const setQueryParams = (params: any) => queryParams$.next(params); + const emitRefresh = () => refresh$.next(); + const setSetupState = (state: SetupState) => { + currentSetupState = state; + nvmeofServiceSpy.fetchSetupState.mockReturnValue(of(currentSetupState)); + }; beforeEach(async () => { - await TestBed.configureTestingModule({ + queryParams$ = new BehaviorSubject({ group: 'grp1' }); + refresh$ = new Subject(); + const nvmeofStateServiceMock = { + refresh$: refresh$.asObservable() + }; + currentSetupState = { hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }; + nvmeofServiceSpy = { + fetchSetupState: jest.fn().mockImplementation(() => of(currentSetupState)) + }; + + TestBed.configureTestingModule({ declarations: [NvmeofTabsComponent], - imports: [RouterTestingModule, SharedModule, TabsModule] - }).compileComponents(); + imports: [ + RouterTestingModule, + HttpClientTestingModule, + SharedModule, + TabsModule, + NvmeofSetupCardsComponent + ], + providers: [ + { provide: NvmeofService, useValue: nvmeofServiceSpy }, + { provide: ActivatedRoute, useValue: { queryParams: queryParams$.asObservable() } } + ] + }); + TestBed.overrideComponent(NvmeofTabsComponent, { + set: { providers: [{ provide: NvmeofStateService, useValue: nvmeofStateServiceMock }] } + }); + await TestBed.compileComponents(); fixture = TestBed.createComponent(NvmeofTabsComponent); component = fixture.componentInstance; router = TestBed.inject(Router); + routerEvents$ = new Subject(); + Object.defineProperty(router, 'url', { + get: () => '/block/nvmeof/gateways', + configurable: true + }); + jest.spyOn(router, 'events', 'get').mockReturnValue(routerEvents$.asObservable()); }); it('should create', () => { @@ -51,25 +104,59 @@ describe('NvmeofTabsComponent', () => { expect(component.activeTab).toBe(component.Tabs.gateways); }); + it('should hide the shell on namespace create routes', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces/create'); + component.ngOnInit(); + expect(component.showTabsShell).toBe(false); + }); + + it('should keep the shell visible on namespace list routes', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces'); + component.ngOnInit(); + expect(component.showTabsShell).toBe(true); + }); + + it('should keep the shell visible on list routes with a secondary outlet', () => { + jest + .spyOn(router, 'url', 'get') + .mockReturnValue('/block/nvmeof/subsystems(modal:create)?group=default'); + component.ngOnInit(); + expect(component.showTabsShell).toBe(true); + }); + + it('should hide the shell when primary route is a create page with secondary outlet', () => { + jest + .spyOn(router, 'url', 'get') + .mockReturnValue('/block/nvmeof/subsystems/create(modal:create)?group=default'); + component.ngOnInit(); + expect(component.showTabsShell).toBe(false); + }); + it('should navigate to correct path on tab selection', () => { spyOn(router, 'navigate'); component.onSelected(component.Tabs.subsystems); - expect(component.selectedTab).toBe(component.Tabs.subsystems); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/subsystems']); + expect(component.activeTab).toBe(component.Tabs.subsystems); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'subsystems'], { + queryParamsHandling: 'preserve' + }); }); it('should navigate to gateways on selecting gateways tab', () => { spyOn(router, 'navigate'); component.onSelected(component.Tabs.gateways); - expect(component.selectedTab).toBe(component.Tabs.gateways); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/gateways']); + expect(component.activeTab).toBe(component.Tabs.gateways); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'gateways'], { + queryParamsHandling: 'preserve' + }); }); it('should navigate to namespaces on selecting namespaces tab', () => { spyOn(router, 'navigate'); component.onSelected(component.Tabs.namespaces); - expect(component.selectedTab).toBe(component.Tabs.namespaces); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/namespaces']); + expect(component.activeTab).toBe(component.Tabs.namespaces); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'namespaces'], { + queryParamsHandling: 'preserve' + }); }); it('should expose TABS enum via Tabs getter', () => { @@ -78,4 +165,229 @@ describe('NvmeofTabsComponent', () => { expect(tabs.subsystems).toBe('subsystems'); expect(tabs.namespaces).toBe('namespaces'); }); + + describe('setup cards scenarios', () => { + it('should show setup cards', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + expect(component.showSetupCards).toBe(true); + }); + + it('should detect subsystems and namespaces regardless of dropdown selection', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({}); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: no gateway groups — all steps pending', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + expect(component.showSetupCards).toBe(true); + }); + + it('scenario: gateway groups exist, no subsystems across all groups — step 1 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: no subsystems in object response across all groups — step 1 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: subsystems in any group, no namespaces — steps 1 & 2 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: all configured across any group — isAllConfigured is true', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: all configured in object response across any group — isAllConfigured is true', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: subsystems exist in grp2 only — step 2 still marked complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: full config in grp2 only — onboarding complete regardless of selected group', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should trigger state reload when refresh$ emits', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + }); + + it('should reload state on NavigationEnd (e.g. navigating back from a form)', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + + routerEvents$.next( + new NavigationEnd(1, '/block/nvmeof/namespaces', '/block/nvmeof/namespaces') + ); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + }); + + it('should show the initial gateway-only state after the last subsystem and namespace are removed', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should show the initial empty state after the last gateway is removed', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should refresh setup cards when the gateway list refreshes to empty', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should show gateway step complete after a gateway is created', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should use fetchSetupState on refresh', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + nvmeofServiceSpy.fetchSetupState.mockClear(); + + emitRefresh(); + + expect(nvmeofServiceSpy.fetchSetupState).toHaveBeenCalledTimes(1); + }); + + it('should render correct setup card messages after all gateway groups are removed', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways'); + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements.length).toBe(3); + expect(cardElements[0].componentInstance.statusMessage).toBe( + 'No gateway groups configured for this cluster yet.' + ); + expect(cardElements[1].componentInstance.statusMessage).toBe('No gateway configured yet.'); + expect(cardElements[2].componentInstance.statusMessage).toBe('No gateway configured yet.'); + }); + + it('should render success setup card messages before gateway groups are removed', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways'); + component.ngOnInit(); + emitRefresh(); + fixture.detectChanges(); + component.showSetupCards = true; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cardElements[1].componentInstance.statusMessage).toBe( + 'Subsystem configured successfully.' + ); + expect(cardElements[2].componentInstance.statusMessage).toBe( + 'Namespaces mapped successfully.' + ); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts index 8b74346db92..a42cd572a2c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts @@ -1,5 +1,10 @@ -import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { Subscription, merge, of } from 'rxjs'; +import { filter, switchMap, tap } from 'rxjs/operators'; + +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; const NVMEOF_PATH = 'block/nvmeof'; @@ -9,26 +14,85 @@ enum TABS { namespaces = 'namespaces' } +const TAB_ROUTES = [ + '/block/nvmeof/gateways', + '/block/nvmeof/subsystems', + '/block/nvmeof/namespaces' +]; + @Component({ selector: 'cd-nvmeof-tabs', templateUrl: './nvmeof-tabs.component.html', styleUrls: ['./nvmeof-tabs.component.scss'], - standalone: false + standalone: false, + providers: [NvmeofStateService] }) -export class NvmeofTabsComponent implements OnInit { - selectedTab: TABS; +export class NvmeofTabsComponent implements OnInit, OnDestroy { activeTab: TABS = TABS.gateways; + showTabsShell = true; + showSetupCards = false; + hasGatewayGroups = false; + hasSubsystems = false; + hasNamespaces = false; + isAllConfigured = false; + private setupSubscription?: Subscription; - constructor(private router: Router) {} + constructor( + private router: Router, + private route: ActivatedRoute, + private nvmeofService: NvmeofService, + private nvmeofStateService: NvmeofStateService + ) {} - ngOnInit(): void { - const currentPath = this.router.url; + private updateActiveTab(currentPath: string): void { this.activeTab = Object.values(TABS).find((tab) => currentPath.includes(tab)) || TABS.gateways; } + private updateShellVisibility(currentPath: string): void { + const urlTree = this.router.parseUrl(currentPath); + const primarySegments = + urlTree.root.children['primary']?.segments.map((segment) => segment.path) ?? []; + const primaryPath = `/${primarySegments.join('/')}`; + + this.showTabsShell = TAB_ROUTES.includes(primaryPath); + } + + ngOnInit(): void { + this.updateActiveTab(this.router.url); + this.updateShellVisibility(this.router.url); + + // Merge all trigger streams to prevent memory leaks and race conditions + this.setupSubscription = merge( + of(null), // Initial load + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + tap((event) => { + this.updateActiveTab(event.urlAfterRedirects); + this.updateShellVisibility(event.urlAfterRedirects); + }) + ), + this.route.queryParams, + this.nvmeofStateService.refresh$ + ) + .pipe(switchMap(() => this.nvmeofService.fetchSetupState())) + .subscribe(({ hasGatewayGroups, hasSubsystems, hasNamespaces }) => { + this.hasGatewayGroups = hasGatewayGroups; + this.hasSubsystems = hasSubsystems; + this.hasNamespaces = hasNamespaces; + this.isAllConfigured = hasGatewayGroups && hasSubsystems && hasNamespaces; + this.showSetupCards = !this.isAllConfigured; + }); + } + + ngOnDestroy(): void { + this.setupSubscription?.unsubscribe(); + } + onSelected(tab: TABS) { - this.selectedTab = tab; - this.router.navigate([`${NVMEOF_PATH}/${tab}`]); + this.activeTab = tab; + this.router.navigate([NVMEOF_PATH, tab], { + queryParamsHandling: 'preserve' + }); } public get Tabs(): typeof TABS { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index 77c31a6553d..121299a9d30 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -3,15 +3,21 @@ import { HttpClient } from '@angular/common/http'; import _ from 'lodash'; import { Observable, forkJoin, of as observableOf } from 'rxjs'; -import { catchError, map, mapTo, mergeMap } from 'rxjs/operators'; +import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators'; import { CephServiceSpec } from '../models/service.interface'; -import { ListenerItem } from '../models/nvmeof'; +import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof'; import { HostService } from './host.service'; import { OrchestratorService } from './orchestrator.service'; import { HostStatus } from '../enum/host-status.enum'; import { Host } from '../models/host.interface'; import { OrchestratorStatus } from '../models/orchestrator.interface'; +export type SetupState = { + hasGatewayGroups: boolean; + hasSubsystems: boolean; + hasNamespaces: boolean; +}; + export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512; export type GatewayGroup = CephServiceSpec; @@ -173,6 +179,60 @@ export class NvmeofService { }, []); } + private normalizeListResponse(response: unknown, key: string): T[] { + if (Array.isArray(response)) { + return response as T[]; + } + + const nested = (response as Record | null)?.[key]; + return Array.isArray(nested) ? nested : []; + } + + fetchSetupState(): Observable { + return this.listGatewayGroups().pipe( + switchMap((gatewayGroups: CephServiceSpec[][]) => { + const rawGroups = Array.isArray(gatewayGroups[0]) ? gatewayGroups[0] : []; + const groups = rawGroups.filter((serviceSpec: CephServiceSpec) => serviceSpec?.spec?.group); + const hasGatewayGroups = groups.length > 0; + + if (!hasGatewayGroups) { + return observableOf({ + hasGatewayGroups: false, + hasSubsystems: false, + hasNamespaces: false + }); + } + + const firstGroupName = groups[0].spec.group; + + return forkJoin({ + subsystems: this.listSubsystems(firstGroupName).pipe( + map((resp: unknown) => this.normalizeListResponse(resp, 'subsystems')), + catchError(() => observableOf([])) + ), + namespaces: this.listNamespaces(firstGroupName).pipe( + map((resp: unknown) => + this.normalizeListResponse(resp, 'namespaces') + ), + catchError(() => observableOf([])) + ) + }).pipe( + map(({ subsystems, namespaces }) => ({ + hasGatewayGroups, + hasSubsystems: subsystems.length > 0, + hasNamespaces: namespaces.length > 0 + })), + catchError(() => + observableOf({ hasGatewayGroups, hasSubsystems: false, hasNamespaces: false }) + ) + ); + }), + catchError(() => + observableOf({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }) + ) + ); + } + // Gateway groups listGatewayGroups() { return this.http.get(`${API_PATH}/gateway/group`); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html new file mode 100644 index 00000000000..d2cd4c23f30 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html @@ -0,0 +1,20 @@ +
+
+ {{ stepNumber }}. + {{ title }} +
+

+ {{ description }} +

+
+ @if (isLoading) { + + } @else if (isConfigured) { + + {{ statusMessage }} + } @else { + + {{ statusMessage }} + } +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss new file mode 100644 index 00000000000..5becfaf4f90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss @@ -0,0 +1,24 @@ +.setup-step-card { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-03); + height: 100%; + + &--loading { + opacity: 0.7; + pointer-events: none; + } + + &__step-row { + display: flex; + align-items: baseline; + gap: var(--cds-spacing-03); + } + + &__info { + display: flex; + align-items: center; + margin-top: auto; + gap: var(--cds-spacing-03); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts new file mode 100644 index 00000000000..0414795e78f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts @@ -0,0 +1,134 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SetupStepCardComponent } from './setup-step-card.component'; + +describe('SetupStepCardComponent', () => { + let component: SetupStepCardComponent; + let fixture: ComponentFixture; + + const STEP_TITLE = 'Example step'; + const STEP_DESCRIPTION = 'Example step description.'; + const SUCCESS_MESSAGE = 'Example configured successfully.'; + const INFO_MESSAGE = 'Example not configured yet.'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SetupStepCardComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(SetupStepCardComponent); + component = fixture.componentInstance; + component.stepNumber = 1; + component.title = STEP_TITLE; + component.description = STEP_DESCRIPTION; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render step number, title, and description', () => { + const element = fixture.nativeElement; + expect(element.textContent).toContain('1.'); + expect(element.textContent).toContain(STEP_TITLE); + expect(element.textContent).toContain(STEP_DESCRIPTION); + }); + + describe('statusMessage', () => { + it('should return successMessage when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + + expect(component.statusMessage).toBe(SUCCESS_MESSAGE); + }); + + it('should return infoMessage when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + + expect(component.statusMessage).toBe(INFO_MESSAGE); + }); + + it('should return default success text when configured without successMessage', () => { + component.isConfigured = true; + component.successMessage = undefined; + + expect(component.statusMessage).toBe('Configured successfully.'); + }); + + it('should return default info text when not configured without infoMessage', () => { + component.isConfigured = false; + component.infoMessage = undefined; + + expect(component.statusMessage).toBe('Not configured yet.'); + }); + + it('should prefer successMessage over infoMessage when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + component.infoMessage = INFO_MESSAGE; + + expect(component.statusMessage).toBe(SUCCESS_MESSAGE); + }); + }); + + describe('template', () => { + it('should render the status message when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + fixture.detectChanges(); + + const message = fixture.nativeElement.querySelector('.setup-step-card__info span'); + expect(message.textContent.trim()).toBe(INFO_MESSAGE); + }); + + it('should render the status message when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + fixture.detectChanges(); + + const message = fixture.nativeElement.querySelector('.setup-step-card__info span'); + expect(message.textContent.trim()).toBe(SUCCESS_MESSAGE); + }); + + it('should render a success icon when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('cd-icon'); + expect(icon.getAttribute('ng-reflect-type')).toBe('success'); + }); + + it('should render an info icon when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('cd-icon'); + expect(icon.getAttribute('ng-reflect-type')).toBe('infoCircle'); + }); + + it('should render loading panel when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + + const loadingPanel = fixture.nativeElement.querySelector('cd-loading-panel'); + expect(loadingPanel).toBeTruthy(); + }); + + it('should return "Loading..." as statusMessage when isLoading is true', () => { + component.isLoading = true; + expect(component.statusMessage).toBe('Loading...'); + }); + + it('should apply loading class when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('.setup-step-card'); + expect(card.classList.contains('setup-step-card--loading')).toBe(true); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts new file mode 100644 index 00000000000..c77b3b5243b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ComponentsModule } from '../components.module'; + +@Component({ + selector: 'cd-setup-step-card', + standalone: true, + imports: [CommonModule, ComponentsModule], + templateUrl: './setup-step-card.component.html', + styleUrls: ['./setup-step-card.component.scss'] +}) +export class SetupStepCardComponent { + @Input() stepNumber!: number; + @Input() title!: string; + @Input() description!: string; + @Input() isConfigured: boolean = false; + @Input() isLoading: boolean = false; + @Input() successMessage?: string; + @Input() infoMessage?: string; + + get statusMessage(): string { + if (this.isLoading) { + return $localize`Loading...`; + } + if (this.isConfigured && this.successMessage) { + return this.successMessage; + } + if (!this.isConfigured && this.infoMessage) { + return this.infoMessage; + } + return this.isConfigured ? $localize`Configured successfully.` : $localize`Not configured yet.`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index f5a7297b167..f76c642986f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -41,6 +41,9 @@ + + + - - + @if (row.cdExecuting) { + + } + {{ value }} - ({{ row.cdExecuting }}) + @if (row.cdExecuting) { + ({{ row.cdExecuting }}) + }
From 918adfa512abf9218c335031fa0d910f7851b1fa Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 23 Oct 2025 11:20:16 +0530 Subject: [PATCH 469/596] mgr/cephadm: Stop NFS service/daemon from starting automatically after reboot, cephadm to manage startup Fixes: https://tracker.ceph.com/issues/73442 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 3 ++ src/pybind/mgr/cephadm/module.py | 36 +++++++++++++------ src/pybind/mgr/cephadm/schedule.py | 3 +- src/pybind/mgr/cephadm/serve.py | 12 +++++-- src/pybind/mgr/cephadm/tests/test_cephadm.py | 1 + src/pybind/mgr/cephadm/tests/test_spec.py | 2 +- src/pybind/mgr/orchestrator/_interface.py | 6 +++- .../orchestrator/tests/test_orchestrator.py | 1 + 8 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 9b3602032d2..ff91ca4cfba 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -1175,12 +1175,15 @@ def deploy_daemon( cephadm_agent.deploy_daemon_unit(config_js) else: if c: + # Disable automatic startup for NFS daemons + enable_daemon = daemon_type != 'nfs' deploy_daemon_units( ctx, ident, uid, gid, c, + enable=enable_daemon, osd_fsid=osd_fsid, endpoints=endpoints, init_containers=init_containers, diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 24c6483007a..91dff724ad6 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -47,7 +47,7 @@ from ceph.deployment.service_spec import ( from ceph.deployment.drive_group import DeviceSelection from ceph.utils import str_to_datetime, datetime_to_str, datetime_now from ceph.cryptotools.select import choose_crypto_caller -from cephadm.serve import CephadmServe, REQUIRES_POST_ACTIONS +from cephadm.serve import CephadmServe from cephadm.services.cephadmservice import CephadmDaemonDeploySpec from cephadm.http_server import CephadmHttpServer from cephadm.agent import CephadmAgentHelpers @@ -1126,6 +1126,12 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): 'unknown': DaemonDescriptionStatus.error, }[d['state']] + cached_dd = None + try: + cached_dd = self.cache.get_daemon(d['name'], host) + except OrchestratorError: + self.log.debug(f'Could not find daemon {d["name"]} in cache') + sd = orchestrator.DaemonDescription( daemon_type=daemon_type, daemon_id='.'.join(d['name'].split('.')[1:]), @@ -1155,16 +1161,10 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): rank_generation=rank_generation, extra_container_args=d.get('extra_container_args'), extra_entrypoint_args=d.get('extra_entrypoint_args'), + pending_daemon_config=cached_dd.pending_daemon_config if cached_dd else False, + user_stopped=cached_dd.user_stopped if cached_dd else False, ) - if daemon_type in REQUIRES_POST_ACTIONS: - # If post action is required for daemon, then restore value of pending_daemon_config - try: - cached_dd = self.cache.get_daemon(sd.name(), host) - sd.update_pending_daemon_config(cached_dd.pending_daemon_config) - except orchestrator.OrchestratorError: - pass - dm[sd.name()] = sd self.log.debug('Refreshed host %s daemons (%d)' % (host, len(dm))) self.cache.update_host_daemons(host, dm) @@ -1180,6 +1180,7 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule): def offline_hosts_remove(self, host: str) -> None: if host in self.offline_hosts: self.offline_hosts.remove(host) + self._invalidate_all_host_metadata_and_kick_serve(host) def update_failed_daemon_health_check(self) -> None: failed_daemons = [] @@ -3088,8 +3089,12 @@ Then run the following: out, err, code = self.wait_async(CephadmServe(self)._run_cephadm( daemon_spec.host, name, 'unit', ['--name', name, a])) - except Exception: - self.log.exception(f'`{daemon_spec.host}: cephadm unit {name} {a}` failed') + except Exception as exp: + if a == 'reset-failed' and daemon_spec.daemon_type in ['nfs'] and 'not loaded' in str(exp): + # Don't log exception if reset-failed fails because the unit is not loaded + pass + else: + self.log.exception(f'`{daemon_spec.host}: cephadm unit {name} {a}` failed') self.cache.invalidate_host_daemons(daemon_spec.host) msg = "{} {} from host '{}'".format(action, name, daemon_spec.host) self.events.for_daemon(name, 'INFO', msg) @@ -3120,6 +3125,7 @@ Then run the following: d = self.cache.get_daemon(daemon_name) assert d.daemon_type is not None assert d.daemon_id is not None + assert d.hostname if (action == 'redeploy' or action == 'restart') and self.daemon_is_self(d.daemon_type, d.daemon_id) \ and not self.mgr_service.mgr_map_has_standby(): @@ -3139,6 +3145,14 @@ Then run the following: f'key rotation not supported for {d.daemon_type}' ) + # Track user-initiated stop/start actions + if action == 'stop': + d.user_stopped = True + self.cache.update_host_daemons(d.hostname, {d.name(): d}) + elif action in ['start', 'restart']: + d.user_stopped = False + self.cache.update_host_daemons(d.hostname, {d.name(): d}) + self._daemon_action_set_image(action, image, d.daemon_type, d.daemon_id) self.log.info(f'Schedule {action} daemon {daemon_name}') diff --git a/src/pybind/mgr/cephadm/schedule.py b/src/pybind/mgr/cephadm/schedule.py index 6df54a5c6e3..c6695994949 100644 --- a/src/pybind/mgr/cephadm/schedule.py +++ b/src/pybind/mgr/cephadm/schedule.py @@ -340,6 +340,7 @@ class HostAssignment(object): # get candidate hosts based on [hosts, label, host_pattern] candidates = self.get_candidates() # type: List[DaemonPlacement] + all_candidates = candidates if self.primary_daemon_type in RESCHEDULE_FROM_OFFLINE_HOSTS_TYPES: # remove unreachable hosts that are not in maintenance so daemons # on these hosts will be rescheduled @@ -400,7 +401,7 @@ class HostAssignment(object): existing_slots: List[DaemonPlacement] = [] to_add: List[DaemonPlacement] = [] to_remove: List[orchestrator.DaemonDescription] = [] - ranks: List[int] = list(range(len(candidates))) + ranks: List[int] = list(range(len(all_candidates))) others: List[DaemonPlacement] = candidates.copy() for dd in daemons: found = False diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 6849c586c33..c518baa4d51 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) REQUIRES_POST_ACTIONS = ['grafana', 'iscsi', 'prometheus', 'alertmanager', 'rgw', 'nvmeof', 'mgmt-gateway'] +DISABLED_SERVICES = ['nfs'] CEPHADM_EXE = ssh.RemoteExecutable('/usr/bin/cephadm') @@ -978,10 +979,11 @@ class CephadmServe: for d in daemons_to_remove: assert d.hostname is not None self._remove_daemon(d.name(), d.hostname) - daemons_to_remove = [] # fence them - svc.fence_old_ranks(spec, rank_map, len(all_slots)) + if daemons_to_remove: + svc.fence_old_ranks(spec, rank_map, len(all_slots)) + daemons_to_remove = [] # create daemons daemon_place_fails = [] @@ -1241,6 +1243,12 @@ class CephadmServe: action, dd.daemon_type, dd.name(), self.mgr, last_config ) + # If no action is specified, check whether we need to start the daemon + if not action and dd.daemon_type in DISABLED_SERVICES: + if dd.status == 0 and not dd.user_stopped: + self.log.debug(f'Starting daemon {dd.name()}') + action = 'start' + if action: if scheduled_action == 'redeploy' and action == 'reconfig': action = 'redeploy' diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py index 79fed21c6c4..ea55695d3c6 100644 --- a/src/pybind/mgr/cephadm/tests/test_cephadm.py +++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py @@ -284,6 +284,7 @@ class TestCephadm(object): 'is_active': False, 'ports': [], 'pending_daemon_config': False, + 'user_stopped': False } ] diff --git a/src/pybind/mgr/cephadm/tests/test_spec.py b/src/pybind/mgr/cephadm/tests/test_spec.py index 6f5a41b7f06..46a702fccf2 100644 --- a/src/pybind/mgr/cephadm/tests/test_spec.py +++ b/src/pybind/mgr/cephadm/tests/test_spec.py @@ -300,7 +300,7 @@ def test_dd_octopus(dd_json): del j['daemon_name'] return j - dd_json.update({'pending_daemon_config': False}) + dd_json.update({'pending_daemon_config': False, 'user_stopped': False}) assert dd_json == convert_to_old_style_json( DaemonDescription.from_json(dd_json).to_json()) diff --git a/src/pybind/mgr/orchestrator/_interface.py b/src/pybind/mgr/orchestrator/_interface.py index 1266b84df55..816c4c43be9 100644 --- a/src/pybind/mgr/orchestrator/_interface.py +++ b/src/pybind/mgr/orchestrator/_interface.py @@ -1226,7 +1226,8 @@ class DaemonDescription(object): rank_generation: Optional[int] = None, extra_container_args: Optional[GeneralArgList] = None, extra_entrypoint_args: Optional[GeneralArgList] = None, - pending_daemon_config: bool = False + pending_daemon_config: bool = False, + user_stopped: bool = False ) -> None: #: Host is at the same granularity as InventoryHost @@ -1302,6 +1303,7 @@ class DaemonDescription(object): self.extra_entrypoint_args = ArgumentSpec.from_general_args( extra_entrypoint_args) self.pending_daemon_config = pending_daemon_config + self.user_stopped = user_stopped def __setattr__(self, name: str, value: Any) -> None: if value is not None and name in ('extra_container_args', 'extra_entrypoint_args'): @@ -1460,6 +1462,7 @@ class DaemonDescription(object): out['rank_generation'] = self.rank_generation out['systemd_unit'] = self.systemd_unit out['pending_daemon_config'] = self.pending_daemon_config + out['user_stopped'] = self.user_stopped for k in ['last_refresh', 'created', 'started', 'last_deployed', 'last_configured']: @@ -1498,6 +1501,7 @@ class DaemonDescription(object): out['ip'] = self.ip out['systemd_unit'] = self.systemd_unit out['pending_daemon_config'] = self.pending_daemon_config + out['user_stopped'] = self.user_stopped for k in ['last_refresh', 'created', 'started', 'last_deployed', 'last_configured']: diff --git a/src/pybind/mgr/orchestrator/tests/test_orchestrator.py b/src/pybind/mgr/orchestrator/tests/test_orchestrator.py index ecd901abbe4..e21bd1280cb 100644 --- a/src/pybind/mgr/orchestrator/tests/test_orchestrator.py +++ b/src/pybind/mgr/orchestrator/tests/test_orchestrator.py @@ -93,6 +93,7 @@ status: 1 status_desc: starting is_active: false pending_daemon_config: false +user_stopped: false events: - 2020-06-10T10:08:22.933241Z daemon:crash.ubuntu [INFO] "Deployed crash.ubuntu on host 'ubuntu'" From 51504dbf2a12a125930b0f4cd14a35553093f820 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 11 Nov 2025 18:08:03 +0530 Subject: [PATCH 470/596] mgr/cephadm: Only start ranked daemon with highest rank gen, if many daemon exists with same rank If the removal of an extra NFS daemon fails for any reason, that daemon should not be started, as the NFS daemon with the highest rank generation is already running. Fixes: https://tracker.ceph.com/issues/73442 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/serve.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index c518baa4d51..e82a6daf1ca 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -1246,8 +1246,28 @@ class CephadmServe: # If no action is specified, check whether we need to start the daemon if not action and dd.daemon_type in DISABLED_SERVICES: if dd.status == 0 and not dd.user_stopped: - self.log.debug(f'Starting daemon {dd.name()}') - action = 'start' + should_start = False + if spec and service_registry.get_service(daemon_type_to_service(dd.daemon_type)).ranked(spec): + # For ranked services, check if we should start this daemon + same_rank_daemons = [ + d for d in self.mgr.cache.get_daemons_by_service(dd.service_name()) + if d.rank == dd.rank and d.daemon_type == dd.daemon_type + ] + self.log.debug( + f'Start hightest rank_gen daemon of same rank daemons {same_rank_daemons}' + ) + if len(same_rank_daemons) == 1: + should_start = True + else: + # Multiple daemons exist for this rank, only start the highest generation + highest_gen_daemon = max(same_rank_daemons, + key=lambda d: d.rank_generation or 0) + should_start = (dd == highest_gen_daemon) + else: + should_start = True + if should_start: + self.log.debug(f'Starting daemon {dd.name()}') + action = 'start' if action: if scheduled_action == 'redeploy' and action == 'reconfig': From 4e20140ecf673f95e9424ab1c938398fa9ea68bc Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Thu, 27 Nov 2025 22:08:42 +0530 Subject: [PATCH 471/596] mgr/cephadm: Fixed cache update for updating daemon user_stopped status Fixes: https://tracker.ceph.com/issues/73442 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/module.py | 8 ++++---- src/pybind/mgr/orchestrator/_interface.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 91dff724ad6..2072a8bf5b4 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -3147,11 +3147,11 @@ Then run the following: # Track user-initiated stop/start actions if action == 'stop': - d.user_stopped = True - self.cache.update_host_daemons(d.hostname, {d.name(): d}) + d.update_user_stopped_status(True) + self.cache.save_host(d.hostname) elif action in ['start', 'restart']: - d.user_stopped = False - self.cache.update_host_daemons(d.hostname, {d.name(): d}) + d.update_user_stopped_status(False) + self.cache.save_host(d.hostname) self._daemon_action_set_image(action, image, d.daemon_type, d.daemon_id) diff --git a/src/pybind/mgr/orchestrator/_interface.py b/src/pybind/mgr/orchestrator/_interface.py index 816c4c43be9..384e743a8c0 100644 --- a/src/pybind/mgr/orchestrator/_interface.py +++ b/src/pybind/mgr/orchestrator/_interface.py @@ -1428,6 +1428,9 @@ class DaemonDescription(object): def update_pending_daemon_config(self, value: bool) -> None: self.pending_daemon_config = value + def update_user_stopped_status(self, value: bool) -> None: + self.user_stopped = value + def __repr__(self) -> str: return "({type}.{id})".format(type=self.daemon_type, id=self.daemon_id) From e33edc889021f4631a73ab1dd565963c52bbff12 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Mon, 23 Mar 2026 16:53:01 +0530 Subject: [PATCH 472/596] mgr/cephadm: Fixed rank calculation logic for potential candidates when NFS daemon colocation is enabled When colocation of NFS daemons is allowed, the existing rank calculation incorrectly derives the rank based solely on the number of hosts. This leads to inaccurate rank assignment because the logic does not account for the full set of potential candidates, especially in scenarios where multiple daemons can reside on the same host. Fixes: https://tracker.ceph.com/issues/73442 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybind/mgr/cephadm/schedule.py b/src/pybind/mgr/cephadm/schedule.py index c6695994949..d2846a853cd 100644 --- a/src/pybind/mgr/cephadm/schedule.py +++ b/src/pybind/mgr/cephadm/schedule.py @@ -401,7 +401,7 @@ class HostAssignment(object): existing_slots: List[DaemonPlacement] = [] to_add: List[DaemonPlacement] = [] to_remove: List[orchestrator.DaemonDescription] = [] - ranks: List[int] = list(range(len(all_candidates))) + ranks: List[int] = list(range(len(all_candidates if len(all_candidates) > len(candidates) else candidates))) others: List[DaemonPlacement] = candidates.copy() for dd in daemons: found = False From 4ca10faf79fa8b2bdd16567f34850751b4c75942 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 28 Apr 2026 19:32:33 +0530 Subject: [PATCH 473/596] mgr/cephadm: Disable keepalived by default and start during serve loop if needed If the ingress service is configured with a placement count lower than the number of hosts, then when one node goes down, Keepalived is configured on the remaining hosts. When the failed host comes back up, if it was previously configured as the master, the VIP may become active on two hosts. To prevent this, Keepalived is disabled by default and started within the service loop. Fixes: https://tracker.ceph.com/issues/76304 Signed-off-by: Shweta Bhosale --- src/cephadm/cephadm.py | 5 +- src/pybind/mgr/cephadm/serve.py | 8 +- src/pybind/mgr/cephadm/services/ingress.py | 52 ++++++- .../cephadm/tests/services/test_ingress.py | 145 +++++++++++++++++- .../mgr/cephadm/tests/services/test_nfs.py | 70 +++++++++ 5 files changed, 275 insertions(+), 5 deletions(-) diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index ff91ca4cfba..f14fe91b85a 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -1175,8 +1175,9 @@ def deploy_daemon( cephadm_agent.deploy_daemon_unit(config_js) else: if c: - # Disable automatic startup for NFS daemons - enable_daemon = daemon_type != 'nfs' + # Disable automatic systemd enable for NFS and keepalived; the mgr + # starts them when appropriate (see cephadm serve / DISABLED_SERVICES). + enable_daemon = daemon_type not in ('nfs', 'keepalived') deploy_daemon_units( ctx, ident, diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index e82a6daf1ca..fe2798984d7 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -34,6 +34,7 @@ from mgr_module import MonCommandFailed from mgr_util import format_bytes from cephadm.services.service_registry import service_registry from cephadm.services.nfs import NFSService +from cephadm.services.ingress import IngressService from . import utils from . import exchange @@ -45,7 +46,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) REQUIRES_POST_ACTIONS = ['grafana', 'iscsi', 'prometheus', 'alertmanager', 'rgw', 'nvmeof', 'mgmt-gateway'] -DISABLED_SERVICES = ['nfs'] +# Daemons not systemd-enabled on deploy, mgr start them when not user stopped +DISABLED_SERVICES = ['nfs', 'keepalived'] CEPHADM_EXE = ssh.RemoteExecutable('/usr/bin/cephadm') @@ -1263,6 +1265,10 @@ class CephadmServe: highest_gen_daemon = max(same_rank_daemons, key=lambda d: d.rank_generation or 0) should_start = (dd == highest_gen_daemon) + elif dd.daemon_type == 'keepalived' and spec is not None: + should_start = IngressService.keepalived_should_auto_start( + self.mgr, dd, spec + ) else: should_start = True if should_start: diff --git a/src/pybind/mgr/cephadm/services/ingress.py b/src/pybind/mgr/cephadm/services/ingress.py index d546d73bae9..84955d75549 100644 --- a/src/pybind/mgr/cephadm/services/ingress.py +++ b/src/pybind/mgr/cephadm/services/ingress.py @@ -8,7 +8,7 @@ from ceph.deployment.service_spec import ServiceSpec, IngressSpec, MonitorCertSo from ceph.deployment.utils import is_ipv6 from mgr_util import build_url from cephadm import utils -from orchestrator import OrchestratorError, DaemonDescription +from orchestrator import OrchestratorError, DaemonDescription, DaemonDescriptionStatus from cephadm.services.cephadmservice import CephadmDaemonDeploySpec, CephService, CephadmService from .service_registry import register_cephadm_service from cephadm.tlsobject_types import TLSCredentials @@ -328,6 +328,56 @@ class IngressService(CephService): return daemon_spec + @staticmethod + def _ingress_keepalived_required_count( + mgr: "CephadmOrchestrator", + ispec: IngressSpec, + ) -> int: + """ + get keepalived slot count from ingress placement only + """ + return ispec.placement.get_target_count(mgr.cache.get_schedulable_hosts()) + + @staticmethod + def keepalived_should_auto_start( + mgr: "CephadmOrchestrator", + dd: DaemonDescription, + spec: ServiceSpec, + ) -> bool: + """ + Whether a stopped keepalived unit should be started by the serve loop. + Does not auto-start while more keepalived daemons exist than the placement target + """ + ispec = cast(IngressSpec, spec) + ingress_daemons = mgr.cache.get_daemons_by_service(ispec.service_name()) + keepalived_total = len( + [d for d in ingress_daemons if d.daemon_type == 'keepalived'] + ) + required = IngressService._ingress_keepalived_required_count(mgr, ispec) + if keepalived_total > required: + return False + if ispec.keepalive_only: + if not ispec.backend_service: + return False + for d in mgr.cache.get_daemons_by_service(ispec.backend_service): + if d.hostname != dd.hostname: + continue + if d.status in ( + DaemonDescriptionStatus.running, + DaemonDescriptionStatus.starting, + ): + return True + return False + for d in mgr.cache.get_daemons_by_service(spec.service_name()): + if d.daemon_type != 'haproxy' or d.hostname != dd.hostname: + continue + if d.status in ( + DaemonDescriptionStatus.running, + DaemonDescriptionStatus.starting, + ): + return True + return False + @staticmethod def get_keepalived_dependencies(mgr: "CephadmOrchestrator", spec: Optional[ServiceSpec]) -> List[str]: # because cephadm creates new daemon instances whenever diff --git a/src/pybind/mgr/cephadm/tests/services/test_ingress.py b/src/pybind/mgr/cephadm/tests/services/test_ingress.py index 47bb1473034..537e77283a9 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_ingress.py +++ b/src/pybind/mgr/cephadm/tests/services/test_ingress.py @@ -14,7 +14,9 @@ from ceph.deployment.service_spec import ( from cephadm.tests.fixtures import with_host, with_service, async_side_effect from ceph.utils import datetime_now -from orchestrator._interface import DaemonDescription +from orchestrator._interface import DaemonDescription, DaemonDescriptionStatus +from orchestrator import HostSpec +from cephadm.services.ingress import IngressService class TestIngressService: @@ -1455,3 +1457,144 @@ class TestIngressService: ) ganesha_conf = nfs_generated_conf['files']['ganesha.conf'] assert "Bind_addr = 10.10.2.20" in ganesha_conf + + +def test_keepalived_should_auto_start_colocated_haproxy(): + mgr = MagicMock() + mgr.cache.get_schedulable_hosts.return_value = [HostSpec('host-a')] + spec = IngressSpec( + service_id='test', + backend_service='rgw.foo', + frontend_port=8080, + monitor_port=8999, + virtual_ip='192.168.1.50/24', + placement=PlacementSpec(hosts=['host-a']), + ) + k = DaemonDescription( + daemon_type='keepalived', + daemon_id='test', + hostname='host-a', + service_name='ingress.test', + ) + hp = DaemonDescription( + daemon_type='haproxy', + daemon_id='test', + hostname='host-a', + service_name='ingress.test', + status=DaemonDescriptionStatus.running, + ports=[8080, 8999], + ) + hp_stopped = DaemonDescription( + daemon_type='haproxy', + daemon_id='test', + hostname='host-a', + service_name='ingress.test', + status=DaemonDescriptionStatus.stopped, + ports=[8080, 8999], + ) + + mgr.cache.get_daemons_by_service.side_effect = lambda n: [] if n == 'ingress.test' else [] + assert not IngressService.keepalived_should_auto_start(mgr, k, spec) + + mgr.cache.get_daemons_by_service.side_effect = lambda n: [hp, k] if n == 'ingress.test' else [] + assert IngressService.keepalived_should_auto_start(mgr, k, spec) + + mgr.cache.get_daemons_by_service.side_effect = ( + lambda n: [hp_stopped, k] if n == 'ingress.test' else [] + ) + assert not IngressService.keepalived_should_auto_start(mgr, k, spec) + + +def test_keepalived_should_auto_start_false_when_more_kv_than_required(): + mgr = MagicMock() + mgr.cache.get_schedulable_hosts.return_value = [ + HostSpec('host-a'), + HostSpec('host-b'), + HostSpec('host-c'), + ] + spec = IngressSpec( + service_id='test', + backend_service='rgw.foo', + frontend_port=8080, + monitor_port=8999, + virtual_ip='192.168.1.50/24', + placement=PlacementSpec(hosts=['host-a', 'host-b']), + ) + hp_a = DaemonDescription( + daemon_type='haproxy', + daemon_id='test', + hostname='host-a', + service_name='ingress.test', + status=DaemonDescriptionStatus.running, + ports=[8080, 8999], + ) + hp_b = DaemonDescription( + daemon_type='haproxy', + daemon_id='test', + hostname='host-b', + service_name='ingress.test', + status=DaemonDescriptionStatus.running, + ports=[8080, 8999], + ) + kv_a = DaemonDescription( + daemon_type='keepalived', + daemon_id='test', + hostname='host-a', + service_name='ingress.test', + ) + kv_b = DaemonDescription( + daemon_type='keepalived', + daemon_id='test', + hostname='host-b', + service_name='ingress.test', + ) + kv_extra = DaemonDescription( + daemon_type='keepalived', + daemon_id='test', + hostname='host-c', + service_name='ingress.test', + ) + mgr.cache.get_daemons_by_service.side_effect = ( + lambda n: [hp_a, hp_b, kv_a, kv_b, kv_extra] if n == 'ingress.test' else [] + ) + assert not IngressService.keepalived_should_auto_start(mgr, kv_extra, spec) + + +def test_keepalived_should_auto_start_keepalive_only_backend(): + mgr = MagicMock() + mgr.cache.get_schedulable_hosts.return_value = [HostSpec('host1')] + spec = IngressSpec( + service_id='test', + backend_service='nfs.foo', + frontend_port=2049, + monitor_port=9049, + virtual_ip='192.168.122.100/24', + keepalive_only=True, + placement=PlacementSpec(hosts=['host1']), + ) + k = DaemonDescription( + daemon_type='keepalived', + daemon_id='keepalived.test', + hostname='host1', + service_name='ingress.test', + ) + nfs_d = DaemonDescription( + daemon_type='nfs', + daemon_id='foo.host1', + hostname='host1', + service_name='nfs.foo', + status=DaemonDescriptionStatus.starting, + ) + + mgr.cache.get_daemons_by_service.side_effect = lambda n: [] + assert not IngressService.keepalived_should_auto_start(mgr, k, spec) + + def _with_nfs(name: str): + if name == 'ingress.test': + return [k] + if name == 'nfs.foo': + return [nfs_d] + return [] + + mgr.cache.get_daemons_by_service.side_effect = _with_nfs + assert IngressService.keepalived_should_auto_start(mgr, k, spec) diff --git a/src/pybind/mgr/cephadm/tests/services/test_nfs.py b/src/pybind/mgr/cephadm/tests/services/test_nfs.py index faf45146fef..cac439c5d26 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_nfs.py +++ b/src/pybind/mgr/cephadm/tests/services/test_nfs.py @@ -1,8 +1,13 @@ import contextlib +from typing import cast from unittest.mock import MagicMock, patch, ANY import pytest +from ceph.utils import datetime_now +from orchestrator import DaemonDescriptionStatus + +from cephadm.serve import CephadmServe from cephadm.services.service_registry import service_registry from cephadm.services.cephadmservice import CephadmDaemonDeploySpec from cephadm.module import CephadmOrchestrator @@ -740,3 +745,68 @@ def test_ingress_for_nfs_choose_next_action(cephadm_module, mock_cephadm): # _daemon_action so we can check what action was chosen mock_cephadm.serve(cephadm_module)._check_daemons() mock_cephadm._daemon_action.assert_called_with(ANY, action="redeploy") + + +@patch("cephadm.services.nfs.NFSService.run_grace_tool", MagicMock()) +@patch("cephadm.services.nfs.NFSService.purge", MagicMock()) +@patch("cephadm.services.nfs.NFSService.create_rados_config_obj", MagicMock()) +def test_check_daemons_starts_keepalived_when_stopped_and_haproxy_running( + cephadm_module, mock_cephadm +): + """Regression: serve loop must issue 'start' for keepalived when deps are ok + and colocated haproxy is running (keepalived is not systemd-enabled).""" + nfs_spec = NFSServiceSpec( + service_id="foo", + placement=PlacementSpec(hosts=['test']), + port=8765, + ) + ingress_spec = IngressSpec( + service_id='bar', + backend_service='nfs.foo', + frontend_port=2468, + monitor_port=8642, + virtual_ip='1.2.3.0/24', + placement=PlacementSpec(hosts=['test']), + keepalived_password='abcde', + ) + with contextlib.ExitStack() as stack: + stack.enter_context(with_host(cephadm_module, "test")) + cephadm_module.cache.update_host_networks( + 'test', + { + '1.2.3.0/24': { + 'if0': [ + '1.2.3.4', + '1.2.3.1', + ] + } + }, + ) + stack.enter_context(with_service(cephadm_module, nfs_spec)) + stack.enter_context(with_service(cephadm_module, ingress_spec)) + + ingress_svc = service_registry.get_service('ingress') + ispec = cast(IngressSpec, cephadm_module.spec_store[ingress_spec.service_name()].spec) + + for dd in list(cephadm_module.cache.get_daemons_by_service(ingress_spec.service_name())): + if dd.daemon_type == 'haproxy' and dd.hostname == 'test': + dd.status = DaemonDescriptionStatus.running + elif dd.daemon_type == 'keepalived' and dd.hostname == 'test': + dd.status = DaemonDescriptionStatus.stopped + assert dd.hostname is not None + deps = ingress_svc.sorted_dependencies( + cephadm_module, ispec, 'keepalived' + ) + cephadm_module.cache.update_daemon_config_deps( + dd.hostname, dd.name(), deps, datetime_now() + ) + + mock_cephadm._daemon_action.reset_mock() + CephadmServe(cephadm_module)._check_daemons() + + keepalived_started = any( + getattr(c.args[0], 'daemon_type', None) == 'keepalived' + and (len(c.args) > 1 and c.args[1] == 'start' or c.kwargs.get('action') == 'start') + for c in mock_cephadm._daemon_action.call_args_list + ) + assert keepalived_started From 6ec51414a9ea2b1bc586356d9511aa2c19bbb951 Mon Sep 17 00:00:00 2001 From: Shweta Bhosale Date: Tue, 28 Apr 2026 21:38:22 +0530 Subject: [PATCH 474/596] mgr/cephadm: Scale down ingress service only when the backend NFS service is scaled down When scaling ingress down with a ranked NFS backend that still has more daemons than its placement target, remove ingress from hosts without NFS first and skip removing co-located ingress on NFS hosts until the next iteration after NFS scales down. Fixes: https://tracker.ceph.com/issues/76304 Signed-off-by: Shweta Bhosale --- src/pybind/mgr/cephadm/schedule.py | 25 ++++++++- src/pybind/mgr/cephadm/serve.py | 12 ++++ .../mgr/cephadm/tests/test_scheduling.py | 55 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/cephadm/schedule.py b/src/pybind/mgr/cephadm/schedule.py index d2846a853cd..b4f9570a3ee 100644 --- a/src/pybind/mgr/cephadm/schedule.py +++ b/src/pybind/mgr/cephadm/schedule.py @@ -219,6 +219,7 @@ class HostAssignment(object): draining_hosts: List[orchestrator.HostSpec], daemons: List[orchestrator.DaemonDescription], related_service_daemons: Optional[List[DaemonDescription]] = None, + related_service_required_count: Optional[int] = None, networks: Dict[str, Dict[str, Dict[str, List[str]]]] = {}, filter_new_host: Optional[Callable[[str, ServiceSpec], bool]] = None, allow_colo: bool = False, @@ -240,6 +241,7 @@ class HostAssignment(object): self.service_name = spec.service_name() self.daemons = daemons self.related_service_daemons = related_service_daemons + self.related_service_required_count = related_service_required_count self.networks = networks self.allow_colo = allow_colo self.per_host_daemon_type = per_host_daemon_type @@ -473,8 +475,27 @@ class HostAssignment(object): # If we still need to remove more, remove from hosts with related services remaining_needed = total_excess - len(to_delete) if remaining_needed > 0: - remaining = [dd for dd in existing if dd not in to_delete] - to_delete.extend(remaining[count:]) + # Restrict defer-removal behavior to ingress only. + should_defer_related_removal = False + if self.spec.service_type == 'ingress': + related_service_count = len(self.related_service_daemons) + related_service_required_count = ( + self.related_service_required_count or count + ) + related_service_is_ranked = any( + dd.rank is not None + for dd in self.related_service_daemons + ) + # For ingress with ranked backends (e.g. NFS), if backend + # scale-down is still pending, defer deleting co-located + # ingress daemons until a later iteration. + should_defer_related_removal = ( + related_service_is_ranked + and related_service_count > related_service_required_count + ) + if not should_defer_related_removal: + remaining = [dd for dd in existing if dd not in to_delete] + to_delete.extend(remaining[count:]) non_matching_daemons = to_delete else: diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index fe2798984d7..24e7e581a9e 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -870,6 +870,17 @@ class CephadmServe: rank_map = None if svc.ranked(spec): rank_map = self.mgr.spec_store[spec.service_name()].rank_map or {} + related_service_required_count = None + if service_type == 'ingress': + ingress_spec = cast(IngressSpec, spec) + assert ingress_spec.backend_service + backend_spec = self.mgr.spec_store.active_specs.get( + ingress_spec.backend_service + ) + if backend_spec and backend_spec.placement: + related_service_required_count = backend_spec.placement.get_target_count( + self.mgr.cache.get_schedulable_hosts() + ) host_selector = _host_selector(svc) ha = HostAssignment( spec=spec, @@ -880,6 +891,7 @@ class CephadmServe: blocking_daemon_hosts=blocking_daemon_hosts, daemons=daemons, related_service_daemons=related_service_daemons, + related_service_required_count=related_service_required_count, networks=self.mgr.cache.networks, filter_new_host=host_filters.get(service_type, None), allow_colo=svc.allow_colo(), diff --git a/src/pybind/mgr/cephadm/tests/test_scheduling.py b/src/pybind/mgr/cephadm/tests/test_scheduling.py index d76204c7ffd..5d3595b95e1 100644 --- a/src/pybind/mgr/cephadm/tests/test_scheduling.py +++ b/src/pybind/mgr/cephadm/tests/test_scheduling.py @@ -2082,3 +2082,58 @@ def test_related_service_downsize(service_type, placement, hosts, daemons, relat assert sorted(got_add) == sorted(expected_add) assert sorted([d.name() for d in to_remove]) == sorted(expected_remove) + + +def test_ingress_scale_down_defers_related_when_ranked_backend_exceeds_required(): + """ + With ingress count below current daemons, ranked NFS backends still above + placement target (related_service_required_count), remove only ingress on + hosts without NFS first; defer removing co-located ingress until NFS scales + down (next iteration). + """ + spec = IngressSpec( + service_type='ingress', + service_id='nfs.test', + frontend_port=443, + monitor_port=8888, + virtual_ip='10.0.0.20/8', + backend_service='nfs.test', + placement=PlacementSpec(count=1), + ) + hosts = [HostSpec(h) for h in 'host1 host2 host3'.split()] + daemons = [ + DaemonDescription('haproxy', 'ingress1', 'host1'), + DaemonDescription('keepalived', 'ingress1', 'host1'), + DaemonDescription('haproxy', 'ingress2', 'host2'), + DaemonDescription('keepalived', 'ingress2', 'host2'), + DaemonDescription('haproxy', 'ingress3', 'host3'), + DaemonDescription('keepalived', 'ingress3', 'host3'), + ] + related_service_daemons = [ + DaemonDescription('nfs', 'nfs1', 'host1', rank=0, rank_generation=0), + DaemonDescription('nfs', 'nfs2', 'host2', rank=1, rank_generation=0), + ] + all_slots, to_add, to_remove = HostAssignment( + spec=spec, + hosts=hosts, + unreachable_hosts=[], + draining_hosts=[], + daemons=daemons, + related_service_daemons=related_service_daemons, + related_service_required_count=1, + primary_daemon_type='haproxy', + per_host_daemon_type='keepalived', + rank_map=None, + ).place() + + assert sorted(str(p) for p in all_slots) == sorted([ + 'haproxy:host1(*:443,8888,1024)', + 'haproxy:host2(*:443,8888,1024)', + 'keepalived:host1', + 'keepalived:host2', + ]) + assert to_add == [] + assert sorted(d.name() for d in to_remove) == sorted([ + 'haproxy.ingress3', + 'keepalived.ingress3', + ]) From 132c77376f6b28990bdabe838192c4a650178016 Mon Sep 17 00:00:00 2001 From: "Jesse F. Williamson" Date: Fri, 12 Sep 2025 13:07:43 -0700 Subject: [PATCH 475/596] Add FoundationDB client library "libfdb". Assisted-by: Codex:GPT-5 Signed-off-by: Jesse F. Williamson --- CMakeLists.txt | 23 +- examples/libfdb/Makefile | 21 + examples/libfdb/mini_example.cc | 77 +++ src/include/buffer.h | 3 + src/rgw/CMakeLists.txt | 54 +- src/rgw/ceph_fdb.h | 96 ++++ src/rgw/fdb/EXAMPLES.md | 324 ++++++++++++ src/rgw/fdb/NOTES.txt | 55 ++ src/rgw/fdb/base.h | 881 ++++++++++++++++++++++++++++++++ src/rgw/fdb/conversion.h | 129 +++++ src/rgw/fdb/fdb.h | 23 + src/rgw/fdb/interface.h | 657 ++++++++++++++++++++++++ src/test/bufferlist.cc | 11 + src/test/rgw/CMakeLists.txt | 38 +- src/test/rgw/test_fdb.cc | 754 +++++++++++++++++++++++++++ src/test/rgw/test_fdb_ceph.cc | 496 ++++++++++++++++++ src/test/rgw/test_fdb_common.h | 138 +++++ 17 files changed, 3755 insertions(+), 25 deletions(-) create mode 100644 examples/libfdb/Makefile create mode 100644 examples/libfdb/mini_example.cc create mode 100644 src/rgw/ceph_fdb.h create mode 100644 src/rgw/fdb/EXAMPLES.md create mode 100644 src/rgw/fdb/NOTES.txt create mode 100644 src/rgw/fdb/base.h create mode 100644 src/rgw/fdb/conversion.h create mode 100644 src/rgw/fdb/fdb.h create mode 100644 src/rgw/fdb/interface.h create mode 100644 src/test/rgw/test_fdb.cc create mode 100644 src/test/rgw/test_fdb_ceph.cc create mode 100644 src/test/rgw/test_fdb_common.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b173c638cd0..43ce6ef4147 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -592,7 +592,7 @@ option(WITH_RADOSGW_DBSTORE "DBStore backend for RADOS Gateway" ON) option(WITH_RADOSGW_MOTR "CORTX-Motr backend for RADOS Gateway" OFF) option(WITH_RADOSGW_DAOS "DAOS backend for RADOS Gateway" OFF) option(WITH_RADOSGW_D4N "D4N wrapper for RADOS Gateway" ON) -cmake_dependent_option(WITH_RADOSGW_POSIX "POSIX backend for RADOS Gateway" ON WITH_RADOSGW_DBSTORE OFF) # posix depends on dbstore +option(WITH_RADOSGW_FDB "FoundationDB support for RADOS Gateway (experimental)" OFF) option(WITH_RADOSGW_RADOS "RADOS backend for Rados Gateway" ON) option(WITH_RADOSGW_SELECT_PARQUET "Support for s3 select on parquet objects" ON) option(WITH_RADOSGW_ARROW_FLIGHT "Build arrow flight when not using system-provided arrow" OFF) @@ -601,6 +601,9 @@ option(WITH_RADOSGW_BACKTRACE_LOGGING "Enable backtraces in rgw logs" OFF) option(WITH_SYSTEM_ARROW "Use system-provided arrow" OFF) option(WITH_SYSTEM_UTF8PROC "Use system-provided utf8proc" OFF) +# POSIX depends on DBStore: +cmake_dependent_option(WITH_RADOSGW_POSIX "POSIX backend for RADOS Gateway" ON WITH_RADOSGW_DBSTORE OFF) + if(WITH_RADOSGW) find_package(EXPAT REQUIRED) find_package(OATH REQUIRED) @@ -654,6 +657,23 @@ if(WITH_RADOSGW) message(STATUS "crypto soname: ${LIBCRYPTO_SONAME}") endif (WITH_RADOSGW) +if(WITH_RADOSGW_FDB) + include(${CMAKE_MODULE_PATH}/CPM.cmake) + + CPMAddPackage("gh:eyalz800/zpp_bits@4.5.1") + message("-- enabled zpp_bits") + +# JFW: sadly, I'm having trouble getting FoundationDB to build, but this is VERY close +# to "just working". But, how would we install the RPMs, etc.? This will need looking at +# before this can be non-experimental: +# CPMAddPackage("gh:apple/foundationdb@7.3.63") + message("FoundationDB support is EXPERIMENTAL and requires manual setup:") + message(" wget https://github.com/apple/foundationdb/releases/download/7.3.63/foundationdb-clients-7.3.63-1.el7.x86_64.rpm") + message(" wget https://github.com/apple/foundationdb/releases/download/7.3.63/foundationdb-server-7.3.63-1.el7.x86_64.rpm") + message("-- enabled FoundationDB (whether its there or not)") + +endif() + #option for CephFS option(WITH_CEPHFS "CephFS is enabled" ON) @@ -924,4 +944,3 @@ add_tags(ctags EXCLUDE_OPTS ${CTAG_EXCLUDES} EXCLUDES "*.js" "*.css" ".tox" "python-common/build") add_custom_target(tags DEPENDS ctags) - diff --git a/examples/libfdb/Makefile b/examples/libfdb/Makefile new file mode 100644 index 00000000000..18304dfa314 --- /dev/null +++ b/examples/libfdb/Makefile @@ -0,0 +1,21 @@ +# Mini-example for libfdb + +CXX?=g++ +CXXFLAGS+=-std=c++23 -Wno-unused-parameter -Wall -Wextra -Werror -g + +CEPH_SRC?=../../src +CEPH_BUILD?=../../build + +INCLUDES=-I$(CEPH_SRC) -I$(CEPH_BUILD)/_deps/zpp_bits-src +LIBRARY_PATH=-L$(CEPH_BUILD)/lib/ -Wl,-rpath,$(CEPH_BUILD)/lib +LIBRARIES=-lfdb_c -lfmtd + +all: mini_example + +.PHONY: clean + +mini_example: mini_example.cc + $(CXX) $(CXXFLAGS) $(INCLUDES) $(LIBRARY_PATH) -o mini_example mini_example.cc $(LIBRARIES) + +clean: + rm -f mini_example diff --git a/examples/libfdb/mini_example.cc b/examples/libfdb/mini_example.cc new file mode 100644 index 00000000000..f1e14af4f67 --- /dev/null +++ b/examples/libfdb/mini_example.cc @@ -0,0 +1,77 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * + * Whee! A mini-demo for libfdb! + * + * This shows how to take a user-defined data stucture and both store and + * retrieve it in FoundationDB. + * +*/ + +#include "rgw/fdb/interface.h" + +#include +#include +#include +#include + +namespace lfdb = ceph::libfdb; + +using namespace std; + +struct person +{ + string name; + + int year_born = 2000; + + vector titles; +}; + +static const vector nifty_people { + { + .name = "Albrecht Duerer", + .year_born = 1471, + .titles = { "Painter", "Printmaker", "Northern Renaissance artist" }, + }, + { + .name = "Maria Theresa", + .year_born = 1717, + .titles = { "Habsburg ruler", "Archduchess of Austria", "Queen of Hungary and Bohemia" }, + } +}; + +int main() +try +{ + auto dbh = lfdb::create_database(); + + lfdb::set(dbh, "example/people", nifty_people); + + vector people; + if(lfdb::get(dbh, "example/people", people)) { + println("read {} people, some of them are likely nifty", people.size()); + } +} +catch(const lfdb::libfdb_exception& e) { + println(stderr, "libfdb exception: {}", e.what()); + return 1; +} +catch(const exception& e) { + println(stderr, "exception: {}", e.what()); + return 1; +} +catch(...) { + println(stderr, "Well, now we're in a real jam, huh?"); + return 1; +} diff --git a/src/include/buffer.h b/src/include/buffer.h index 73b7309c15a..a182fe06aa2 100644 --- a/src/include/buffer.h +++ b/src/include/buffer.h @@ -289,6 +289,7 @@ struct error_code; const char *end_c_str() const; char *end_c_str(); unsigned length() const { return _len; } + unsigned size() const { return length(); } unsigned offset() const { return _off; } unsigned start() const { return _off; } unsigned end() const { return _off + _len; } @@ -1010,6 +1011,7 @@ struct error_code; const buffers_t& buffers() const { return _buffers; } buffers_t& mut_buffers() { return _buffers; } void swap(list& other) noexcept; + unsigned length() const { #if 0 // DEBUG: verify _len @@ -1027,6 +1029,7 @@ struct error_code; #endif return _len; } + unsigned size() const { return length(); } bool contents_equal(const buffer::list& other) const; bool contents_equal(const void* other, size_t length) const; diff --git a/src/rgw/CMakeLists.txt b/src/rgw/CMakeLists.txt index a8082f580ad..875c5b21397 100644 --- a/src/rgw/CMakeLists.txt +++ b/src/rgw/CMakeLists.txt @@ -262,19 +262,22 @@ endif() if(WITH_RADOSGW_DAOS) list(APPEND librgw_common_srcs driver/motr/rgw_sal_daos.cc) endif() + if(WITH_RADOSGW_POSIX) #add_subdirectory(driver/posix) find_package(LMDB REQUIRED) add_compile_definitions(LMDB_SAFE_NO_CPP_UTILITIES) list(APPEND librgw_common_srcs - driver/posix/rgw_sal_posix.cc driver/posix/lmdb-safe.cc - driver/posix/posixDB.cc - driver/posix/notify.cpp) + driver/posix/notify.cpp + driver/posix/posixDB.cc + driver/posix/rgw_sal_posix.cc) endif() + if(WITH_JAEGER) list(APPEND librgw_common_srcs rgw_tracer.cc) endif() + if(WITH_RADOSGW_ARROW_FLIGHT) # NOTE: eventually don't want this in common but just in radosgw daemon # list(APPEND radosgw_srcs rgw_flight.cc rgw_flight_frontend.cc) @@ -393,10 +396,6 @@ if(WITH_RADOSGW_DBSTORE) target_link_libraries(rgw_common PRIVATE global dbstore) endif() -if(WITH_RADOSGW_POSIX) - target_link_libraries(rgw_common PRIVATE global dbstore) -endif() - if(WITH_RADOSGW_MOTR) find_package(motr REQUIRED) target_link_libraries(rgw_common PRIVATE motr::motr) @@ -516,6 +515,7 @@ set(radosgw_srcs rgw_main.cc) add_executable(radosgw ${radosgw_srcs}) + if(WITH_RADOSGW_ARROW_FLIGHT) # target_compile_definitions(radosgw PUBLIC WITH_ARROW_FLIGHT) target_compile_definitions(rgw_common PUBLIC WITH_ARROW_FLIGHT) @@ -538,6 +538,46 @@ target_link_libraries(radosgw PRIVATE ${rgw_libs} ${ALLOC_LIBS}) +if(WITH_RADOSGW_FDB) + message("FoundationDB support is active (experimental)") + + add_library(rgw_fdb INTERFACE) + + target_link_libraries(rgw_fdb INTERFACE + -lfdb_c + -lm + -lrt + ) + + target_compile_options(rgw_fdb INTERFACE + -pthread) + +#JFW: this is where the FDB client library packages install +#fdb_c headers-- we need to find a smarter way to handle this +#end of it before libfdb can be non-experimental: + target_include_directories(rgw_fdb INTERFACE + /usr/include/foundationdb) + + target_include_directories(rgw_common PRIVATE + /usr/include/foundationdb + "${CMAKE_SOURCE_DIR}/src/rgw") + + target_link_libraries(rgw_common PRIVATE + rgw_fdb) + + target_include_directories(radosgw PRIVATE + /usr/include/foundationdb) + + # Use the zpp_bits back-end for serialization: + add_library(zpp_bits INTERFACE) + + # JFW: there must be a nicer way to refer to this path (or ask it to be installed somewhere?): + target_include_directories(zpp_bits INTERFACE "${CMAKE_BINARY_DIR}/_deps/zpp_bits-src/") + + target_link_libraries(rgw_fdb INTERFACE zpp_bits) + +endif(WITH_RADOSGW_FDB) + install(TARGETS radosgw DESTINATION bin) set(radosgw_admin_srcs diff --git a/src/rgw/ceph_fdb.h b/src/rgw/ceph_fdb.h new file mode 100644 index 00000000000..763828c1c19 --- /dev/null +++ b/src/rgw/ceph_fdb.h @@ -0,0 +1,96 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2025-2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * +*/ + +#ifndef CEPH_FDB_H + #define CEPH_FDB_H + +/* Welcome, brave adventurer! This where Ceph types are converted back and forth +between FDB's types! If you have a user type to add, this is the place! +*/ + +#include "include/buffer.h" + +#include "fdb/fdb.h" + +#include +#include +#include +#include + +/*** Conversions: */ + +namespace ceph::libfdb::detail { + +auto as_fdb_span(ceph::buffer::list& bl) +{ + // c_str() makes the buffer::list contiguous. Use length(), not C-string + // rules, because buffer::list may contain arbitrary bytes: + auto p = bl.c_str(); + + return std::span( + reinterpret_cast(p), bl.length()); +} + +} // namespace ceph::libfdb::detail + +namespace ceph::buffer { + +auto serialize(auto& archive, ceph::buffer::list& target) +{ + // This should be revisited after the library is separated from the larger + // surrounding project-- essentially, we can't write directly /into/ the buffer::list + // that I know of; yet, it would obviously be great to eliminate the copy + // here. I believe that somewhere in zpp::bits there's probably a way to get the + // archive to call a custom function-- but it is not clear to me at this time and + // I need to move on for now, unfortunately: + std::vector out; + + auto r = archive(out); + + // JFW: this is really annoying, but again buffer::list is not something I find + // easy to wrangle-- I'm not sure there's a straightforward way to just assign + // a new value... so if you know what that is, please improve this: + target.clear(); + target.append(out); + + return r; +} + +auto serialize(auto& archive, const ceph::buffer::list& src) +{ + std::vector bytes; + + auto i = std::cbegin(src); + + // buffer::list copy already resizes the vector: + i.copy(src.length(), bytes); + + return archive(std::span { bytes.data(), bytes.size() }); +} + +// This transliteration is one-way: we read from a buffer::ptr to an archive; writing +// to a ceph::buffer::ptr as output seems pointless to me, as we already support spans +// and buffer::list-- I feel in fact like we would be courting danger we don't need as it +// is a non-owning structure. +auto serialize(auto& archive, const ceph::buffer::ptr& src) +{ + std::span src_span((std::uint8_t *)src.c_str(), src.length()); + + return archive(src_span); +} + +} // namespace ceph::buffer + +#endif diff --git a/src/rgw/fdb/EXAMPLES.md b/src/rgw/fdb/EXAMPLES.md new file mode 100644 index 00000000000..f06f24d6890 --- /dev/null +++ b/src/rgw/fdb/EXAMPLES.md @@ -0,0 +1,324 @@ +# libfdb Examples + +"Take a thousand days of practice for forging, and ten thousand days of practice for refining." + -- Miyamoto Musashi, Go Rin No Sho (~1645) + +Welcome, traveller! Grab your favorite walking stick, and let us journey into +the realm of libfdb! + +While this is not proper documentation, hopefully this "cookbook-stye" set +of mini-examples will help you on your libfdb path. + +Errata: Please report errata, or contact with examples you would like to see! + +These examples use a short namespace alias for readability and also to save +typing with one's poor fingers! Yoikes! + +See examples/libfdb/ for some working, compilable simple examples. + +```cpp +namespace lfdb = ceph::libfdb; +using namespace std::string_literals; +``` + +## General Recipes + +```cpp +/* Use a database_handle when you desire a single logical operation. Behind + * the scenes, libfdb will create and complete its own transaction for you. + * Database-handle operations may retry after recoverable FoundationDB errors, + * so callbacks and output iterators may be activated more than once. */ +lfdb::set(dbh, "person/barbara-moo/name", "Barbara Moo"); +``` + +```cpp +/* Pass a transaction handle when several operations must be grouped in the + * same transaction. Do not use the transaction after commit(). */ +auto txn = lfdb::make_transaction(dbh); + +lfdb::set(txn, "person/barbara-moo/name", "Barbara Moo"); +lfdb::set(txn, "person/barbara-moo/book", "Accelerated C++"); + +if (!lfdb::commit(txn)) { + /* Retry the transaction body with a fresh or recovered transaction. */ +} +``` + +## Setup + +```cpp +/* Open the default FoundationDB database. */ +auto dbh = lfdb::create_database(); +``` + +```cpp +/* Open a database with database and network options. Flag-only options use + * lfdb::option_flag because they have no value. Network options are applied + * only during the first FoundationDB network initialization; later calls to + * create_database() cannot change them. */ +lfdb::database_options dbopts{ + { FDB_DB_OPTION_TRANSACTION_TIMEOUT, std::int64_t{5000} }, +}; + +lfdb::network_options netopts{ + { FDB_NET_OPTION_TRACE_ENABLE, lfdb::option_flag }, +}; + +auto dbh = lfdb::create_database(dbopts, netopts); +``` + +```cpp +/* Open a database with an explicit cluster file plus database/network options. */ +auto dbh = lfdb::create_database("/path/to/fdb.cluster", dbopts, netopts); +``` + +## Single-Key Operations + +```cpp +/* Store and retrieve one value by key. */ +lfdb::set(dbh, "person/konrad-zuse/name", "Konrad Zuse"); + +std::string name; +if (lfdb::get(dbh, "person/konrad-zuse/name", name)) { + /* use name */ +} +``` + +```cpp +/* Use a callback when the raw serialized bytes must be copied or decoded + * immediately. The span is only valid during the callback. */ +lfdb::get(dbh, "person/konrad-zuse/name", + [](std::span bytes) { + /* copy or decode bytes here */ + }); +``` + +## Key Existence And Erase + +```cpp +/* Check for a key and erase it if it exists. */ +if (lfdb::key_exists(dbh, "person/jose-capablanca/title")) { + lfdb::erase(dbh, "person/jose-capablanca/title"); +} +``` + +## Multi-Key Writes + +```cpp +/* Write key/value pairs from an STL associative container in one transaction. */ +std::map people{ + { "person/saladin/name", "Saladin" }, + { "person/al-khwarizmi/name", "Al-Khwarizmi" }, + { "person/albrecht-duerer/name", "Albrecht Duerer" }, +}; + +lfdb::set(dbh, std::begin(people), std::end(people)); +``` + +## Multi-Key Reads + +```cpp +/* Read a key range into an STL associative container. */ +std::map people; + +lfdb::get(dbh, + lfdb::select { "person/" }, + std::inserter(people, std::end(people))); +``` + +## Key Ordering + +```cpp +/* FoundationDB keys are ordered lexicographically by byte string. Choose key + * formats so lexical order matches the scan order you want. Numeric suffixes + * should usually be fixed-width and zero-padded. */ +lfdb::set(dbh, "person/000001/name", "Barbara Moo"); +lfdb::set(dbh, "person/000010/name", "Konrad Zuse"); +``` + +## Prefix Selection + +"select" has two constructor forms. The one-argument form is usually the one +you want: it selects every key with a shared prefix. This is a natural fit for +FoundationDB key design, where related records are commonly grouped under a +prefix such as `person/`, `bucket/index/`, or `object/metadata/`. + +```cpp +/* Select all keys beginning with "person/". */ +auto people = lfdb::select { "person/" }; +``` + +Using a key beginning with 0xFF will result in unpredictable behavior. + +## Explicit Key Ranges + +```cpp +/* Select a half-open lexicographic key range: begin is included, end is + * excluded. */ +auto medieval_people = lfdb::select { "person/charlemagne", "person/saladin/" }; +``` + +## Pair Generator + +```cpp +/* Use pair_generator() for ordinary range scans where processing one key/value + * pair at a time is natural. */ +std::map people; + +std::ranges::copy(lfdb::pair_generator(dbh, lfdb::select { "person/" }), + std::inserter(people, std::end(people))); +``` + +## Block Generator + +For very large prefix scans, use the same one-argument selector with +`block_generator()`. This lets libfdb plan and read the range in blocks while +the call site still names the logical key family directly. + +```cpp +/* Use block_generator() for large range scans where split planning and + * block-at-a-time processing are useful. */ +for (auto&& block : lfdb::block_generator(dbh, lfdb::select { "object/metadata/" })) { + /* process block */ +} +``` + +## STL Containers As Values + +```cpp +/* Store an STL container as one serialized value. */ +std::vector roles{ "compiler"s, "systems"s, "naval-officer"s }; + +lfdb::set(dbh, "person/grace-hopper/roles", roles); + +std::vector out_roles; +lfdb::get(dbh, "person/grace-hopper/roles", out_roles); +``` + +## Associative Containers As Values + +```cpp +/* Store an associative container as one serialized value. */ +std::map profile{ + { "name", "Maria Theresa" }, + { "title", "Archduchess of Austria" }, +}; + +lfdb::set(dbh, "person/maria-theresa/profile", profile); + +std::map out_profile; +lfdb::get(dbh, "person/maria-theresa/profile", out_profile); +``` + +## User Types As Values + +```cpp +/* Store a user-defined type as one serialized value. */ +struct person_profile +{ + using serialize = zpp::bits::members<3>; + + std::string name; + std::string field; + std::vector tags; +}; + +auto profile = person_profile{ + .name = "Edsger Dijkstra", + .field = "computer science", + .tags = std::vector{ "algorithms"s, "formal-methods"s }, +}; + +lfdb::set(dbh, "person/edsger-dijkstra/profile", profile); + +person_profile out_profile; +lfdb::get(dbh, "person/edsger-dijkstra/profile", out_profile); +``` + +## Manual Transactions + +```cpp +/* Group multiple operations in one explicit transaction. */ +auto txn = lfdb::make_transaction(dbh); + +lfdb::set(txn, "person/matilda-of-tuscany/name", "Matilda of Tuscany"); +lfdb::set(txn, "person/matilda-of-tuscany/title", "Margravine"); + +if (!lfdb::commit(txn)) { + /* Retry the transaction body. */ +} +``` + +## Manual Transactions With Options + +```cpp +/* Create an explicit transaction with transaction options. */ +lfdb::transaction_options opts{ + { FDB_TR_OPTION_READ_YOUR_WRITES_DISABLE, lfdb::option_flag }, +}; + +auto txn = lfdb::make_transaction(dbh, opts); + +lfdb::set(txn, "person/hypatia/name", "Hypatia"); + +if (!lfdb::commit(txn)) { + /* Retry the transaction body. */ +} +``` + +## Transactors: Replayable Transactions + +Transactors are function objects created with `make_transactor()`. Creating a +transactor does not start a transaction; calling `operator()` creates the +transaction, invokes the body, and commits it. + +The body may be called more than once after retryable FoundationDB errors. Keep +it deterministic and free of non-idempotent external side effects. If recovery +is not possible, or if user code throws, the exception escapes to the caller. + +```cpp +/* Use a transactor when the transaction body should be replayed after retryable + * FoundationDB errors. */ +auto txr = lfdb::make_transactor(dbh); + +txr([](auto& txn) { + lfdb::set(txn, "person/eleanor-of-aquitaine/name", "Eleanor of Aquitaine"); + lfdb::set(txn, "person/eleanor-of-aquitaine/title", "Duchess of Aquitaine"); +}); +``` + +### Transactor options + +```cpp +/* Options are applied to each transaction the transactor creates. */ +lfdb::transaction_options opts{ + { FDB_TR_OPTION_READ_YOUR_WRITES_DISABLE, lfdb::option_flag }, +}; + +auto txr = lfdb::make_transactor(dbh, opts); + +txr([](auto& txn) { + lfdb::set(txn, "person/zenobia/name", "Zenobia"); +}); +``` + +```cpp +/* Retryable FoundationDB errors are handled before control returns here. */ +auto txr = lfdb::make_transactor(dbh); + +try { + txr([](auto& txn) { + /* User exceptions propagate; the body is not committed. */ + validate_profile_update(); + + lfdb::set(txn, "person/jose-capablanca/title", + std::vector{ "Original Grandmaster"s, "World Chess Champion"s }); + }); +} +catch (const lfdb::libfdb_exception& e) { + /* FoundationDB reported an error that libfdb could not recover from. */ +} +catch (const std::exception& e) { + /* Application or system error from user code. */ +} +``` diff --git a/src/rgw/fdb/NOTES.txt b/src/rgw/fdb/NOTES.txt new file mode 100644 index 00000000000..2f01767bb9b --- /dev/null +++ b/src/rgw/fdb/NOTES.txt @@ -0,0 +1,55 @@ + +* If you're looking for some information about how to use the library, interface.h is the "user interface". + +---- +Network and Database Options: + +There are various database at network settings that can be adjusted; see the "options" test for some examples of using +the network_options and database_options structures. But what may be tweaked, you ask? Excellent question-- and when +you go to fdb_c's documentation, you will find little, because they are GENERATED! And, from whence? Well... because +this file is VERY hard to find (in the FDB source), here you are: + fdbclient/vexillographer/fdb.options + +---- +Equivalence with FDBStreamingMode: + +FDB_STREAMING_MODE_ITERATOR + +The caller is implementing an iterator (most likely in a binding to a higher level language). The amount of data returned depends on the value of the iteration parameter to fdb_transaction_get_range(). + +FDB_STREAMING_MODE_SMALL + +Data is returned in small batches (not much more expensive than reading individual key-value pairs). + +FDB_STREAMING_MODE_MEDIUM + +Data is returned in batches between _SMALL and _LARGE. + +FDB_STREAMING_MODE_LARGE + +Data is returned in batches large enough to be, in a high-concurrency environment, nearly as efficient as possible. If the caller does not need the entire range, some disk and network bandwidth may be wasted. The batch size may be still be too small to allow a single client to get high throughput from the database. + +FDB_STREAMING_MODE_SERIAL + +Data is returned in batches large enough that an individual client can get reasonable read bandwidth from the database. If the caller does not need the entire range, considerable disk and network bandwidth may be wasted. + +FDB_STREAMING_MODE_WANT_ALL + +The caller intends to consume the entire range and would like it all transferred as early as possible. + +FDB_STREAMING_MODE_EXACT + +The caller has passed a specific row limit and wants that many rows delivered in a single batch. + +enum struct streaming_mode_t : int { + iterator = FDB_STREAMING_MODE_ITERATOR, + small = FDB_STREAMING_MODE_SMALL, + medium = FDB_STREAMING_MODE_MEDIUM, + large = FDB_STREAMING_MODE_LARGE, + serial = FDB_STREAMING_MODE_SERIAL, + all = FDB_STREAMING_MODE_WANT_ALL, +3F exact = FDB_STREAMING_MODE_EXACT +}; + +...these are not defined in terms of int or enum as far as I can tell, needs more exploring. + diff --git a/src/rgw/fdb/base.h b/src/rgw/fdb/base.h new file mode 100644 index 00000000000..b62ce6e855c --- /dev/null +++ b/src/rgw/fdb/base.h @@ -0,0 +1,881 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- // vim: ts=8 sw=2 smarttab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2025-2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * +*/ + +#ifndef CEPH_FDB_BASE_H + #define CEPH_FDB_BASE_H + +// The API version we're writing against, which can (and probably does) differ +// from the installed version. This must be defined before the FoundationDB header +// is included (see fdb_c_apiversion.g.h): +#define FDB_API_VERSION 730 +#include + +// Ceph uses libfmt rather than : +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cpp_lib_flat_map + #include + template + using flat_map = std::flat_map; +#else + #include + template + using flat_map = boost::container::flat_map; +#endif + +// Wrangle some forward declarations: +namespace ceph::libfdb { + +struct select; + +class database; +class transaction; + +using database_handle = std::shared_ptr; +using transaction_handle = std::shared_ptr; + +extern transaction_handle make_transaction(database_handle dbh); + +} // namespace ceph::libfdb + +// MOAR forward declarations-- "pay no attention to that man behind the curtain": +namespace ceph::libfdb::detail { + +template +concept is_any_of = (std::is_same_v || ...); + +struct future_value; + +template +std::pair to_decoded_kv_pair(const FDBKeyValue& kv); + +inline fdb_error_t do_commit(transaction_handle& txn); + +inline void transaction_set_kv_bytes(const transaction_handle& txn, + std::span k, + std::span v); + +inline future_value await_ready_key_range_future(transaction_handle txn, const ceph::libfdb::select&, int); +inline future_value await_ready_keyvalue_range_future(transaction_handle txn, const ceph::libfdb::select& key_range, int& iteration); + +// A generator that produces successive spans for a range: +inline std::generator> generate_FDB_pairs(ceph::libfdb::transaction& txn, ceph::libfdb::select key_range); + +// Stores a successively-generated of kv pair results to an iterator: +template +requires std::output_iterator> +inline bool get_value_range_from_transaction(ceph::libfdb::transaction& txn, const ceph::libfdb::select& key_range, OutIterT out_iter); + +} // namespace ceph::libfdb::detail + +namespace ceph::libfdb::concepts { + +// Note that "stringlikes" are not all "stringview-likes", such as when they can be +// written to: +template +concept stringview_convertible = std::convertible_to; + +template +concept key_value_iterator = + std::input_iterator and + requires(const std::iter_value_t& kv) { + kv.first; + kv.second; + }; + +template +concept value_callback = + std::invocable>; + +template +concept value_output = + !value_callback> && + std::is_lvalue_reference_v; + +template +concept storable_invocation_result = + !std::is_void_v && !std::is_reference_v; + +template +concept supported_invocation_result = + std::is_void_v || storable_invocation_result; + +// There's a high likelihood that we're going to get more sophisticated selectors, +// so this is doing a more important job than it may appear to be: +template +concept selector = ceph::libfdb::detail::is_any_of; + +} // namespace ceph::libfdb::concepts + +// libfdb_exception: How to deal, when Bad Things(TM) happen: +namespace ceph::libfdb { + +// Should we commit after the (possibly) mutating operation? +enum struct commit_after_op { commit, no_commit }; + +struct libfdb_exception final : std::runtime_error +{ + using std::runtime_error::runtime_error; + + fdb_error_t fdb_error_value = -1; + + bool retryable() const noexcept + { + return 0 < fdb_error_value && + fdb_error_predicate(FDB_ERROR_PREDICATE_RETRYABLE, fdb_error_value); + } + + libfdb_exception(std::string_view msg) + : std::runtime_error(make_error_string(msg)) + {} + + explicit libfdb_exception(fdb_error_t fdb_error_value) + : std::runtime_error(make_fdb_error_string(fdb_error_value)), + fdb_error_value(fdb_error_value) + {} + + static std::string make_error_string(const std::string_view msg) + { + return fmt::format("libfdb: {}", msg); + } + + static std::string make_fdb_error_string(const fdb_error_t ec) + { + return make_error_string(fmt::format("FoundationDB error {}: {}", ec, fdb_get_error(ec))); + } +}; + +// A straightforward but pretty handy predicate-- ask an exception if it's something the +// user can try replaying an operation to correct: +inline bool retryable(const libfdb_exception& e) noexcept +{ + return e.retryable(); +} + +namespace detail { + +struct future_value final +{ + std::unique_ptr future_ptr; + + public: + explicit future_value(FDBFuture *future_handle) + : future_ptr(future_handle, &fdb_future_destroy) + {} + + public: + FDBFuture *raw_handle() const noexcept { return future_ptr.get(); } + + private: + void destroy() noexcept { future_ptr.reset(nullptr); } + + private: + friend class ceph::libfdb::transaction; +}; + +constexpr auto as_fdb_span(const char *s) +{ + return std::span((const std::uint8_t *)s, std::strlen(s)); +} + +constexpr auto as_fdb_span(std::string_view sv) +{ + return std::span((const std::uint8_t *)sv.data(), sv.size()); +} + +/* Prefix selectors assume user keys do not start with 0xFF. Appending 0xFF +would exclude valid keys (beginning with prefix + 0xFF), so we build the +next-in-lexicographic-order key instead. Callers can still specify an +explicit full range if they need something special: */ +inline std::string make_range_end_key_for_prefix(std::string_view prefix) +{ + if (prefix.empty()) { + return "\xFF"; + } + + if (0xFF == static_cast(prefix.front())) { + throw libfdb_exception("requested prefix has no (finite) end key"); + } + + auto i = prefix.find_last_not_of(static_cast(0xFF)); + auto end_key = std::string(prefix.substr(0, i + 1)); + + end_key.back() = static_cast(static_cast(end_key.back()) + 1); + + return end_key; +} + +} // namespace ceph::libfdb::detail + +/* lfdb::select is for handling batch queries-- there are two constructors to consider: + - single-key select creates the range containing all keys with that prefix; + - passing a start and end key creates a half-open lexicographic key range: [begin_key, end_key). */ +struct select final +{ + std::string begin_key, end_key; + + public: + // We'll eventually need a way to get settings into the base library from the binding layer; there + // are a few ways we could do it, this is one I'm mulling over; do not use this right now. + mutable struct { + int stride = 0; // "unlimited" + + // Some parts of the documentation claim FDB_STREAMING_MODE_ITERATOR is the default, other parts + // don't... examples tend to use FDB_STREAMING_MODE_WANT_ALL, but they operate on fairly small amounts + // of data. It's pretty hard to understand what the Right Thing(TM) to do is, the sure the answer may + // evolve as I learn more, but for now this setting at least means it's "plumbed through" (this + // particular mode starts with small batches and then grows to larger increments as more data is sent), + // even though this isn't really used at the moment: + FDBStreamingMode streaming_mode = FDB_STREAMING_MODE_ITERATOR; + + } options; + + public: + select(std::string_view begin_key_, std::string_view end_key_) + : begin_key(begin_key_), end_key(end_key_) + {} + + select(std::string_view prefix) + : begin_key(prefix), + end_key(detail::make_range_end_key_for_prefix(prefix)) + {} + +}; + +// Flag-only options are indicated with an explicit option_flag, as they have no actual value: +struct option_flag_t final {}; +inline constexpr option_flag_t option_flag; + +using option_value = std::variant>; + +// i.e. option /code/ to the value of the option itself (e.g. FDB_FOO, 42): +template +using option_map = flat_map; + +using network_options = option_map; +using database_options = option_map; +using transaction_options = option_map; + +namespace detail { + +// Note that these are specific to FDB's needs (see return type, casts): +constexpr const std::uint8_t *data_of(const std::vector& xs) { return (const std::uint8_t *)xs.data(); } +constexpr const std::uint8_t *data_of(const std::string& xs) { return (const std::uint8_t *)xs.data(); } +constexpr const std::uint8_t *data_of(const std::int64_t& x) { return reinterpret_cast(&x); } +constexpr const std::uint8_t *data_of(const option_flag_t&) { return nullptr; } + +// Note that these are specific to FDB's needs (see return type, casts): +constexpr int size_of(const std::vector& xs) { return static_cast(xs.size()); } +constexpr int size_of(const std::string& xs) { return static_cast(xs.size()); } +constexpr int size_of(const std::int64_t) { return 8; } // 8b = size required by FDB +constexpr int size_of(const option_flag_t&) { return 0; } + +// ...also used: +constexpr std::uint8_t *data_of(std::string_view xs) { return (std::uint8_t *)xs.data(); } +constexpr int size_of(std::string_view xs) { return static_cast(xs.size()); } + +inline auto as_fdb_option_args(const auto& option) +{ + auto [data, size] = std::visit([](const auto& x) { + return std::tuple { data_of(x), size_of(x) }; + }, + option.second); + + return std::tuple { option.first, data, size }; +} + +inline void apply_options(const auto& option_map, auto&& set_option) +{ + std::ranges::for_each(option_map, [&set_option](const auto& option) { + auto args = as_fdb_option_args(option); + + if (auto r = std::apply(set_option, args); 0 != r) { + throw libfdb_exception(fmt::format("while setting option {}; {}", + static_cast(option.first), + libfdb_exception::make_fdb_error_string(r))); + } + }); +} + +// The global DB state and management thread: +// JFW: more user hooks that go into FDB system possible here +class database_system final +{ + database_system() = delete; + + private: + static inline bool was_initialized = false; + + static inline std::once_flag fdb_was_initialized; + static inline std::jthread fdb_network_thread; + + static inline void initialize_fdb(const network_options& options) + { + // This must be called before ANY other API function: + if (fdb_error_t r = fdb_select_api_version(FDB_API_VERSION); 0 != r) + throw libfdb_exception(r); + + // Zero or more calls to this may now be made: + apply_options(options, fdb_network_set_option); + + // This must be called before any other API function (besides >= 0 calls to fdb_network_set_option()): + if (fdb_error_t r = fdb_setup_network(); 0 != r) + throw libfdb_exception(r); + + // Launch network thread: + fdb_network_thread = std::jthread { &fdb_run_network }; + + // Okie-dokie, we're all set (distinct from fdb_was_initialized): + was_initialized = true; + } + + private: + database_system(const network_options& network_opts) + { + std::call_once(ceph::libfdb::detail::database_system::fdb_was_initialized, + ceph::libfdb::detail::database_system::initialize_fdb, + network_opts); + } + + public: + static bool& initialized() noexcept { return was_initialized; } + + public: + static inline void shutdown_fdb() + { + using namespace std::chrono_literals; + + if (not initialized()) { + return; + } + + // shut down network and database: + if (int r = fdb_stop_network(); 0 != r) + { + // In this case, we likely don't want to throw from our dtor, but we may + // have something to log. As there's no higher-level hook for that, we + // have nothing to do right now. + // fmt::println("database::shutdown_fdb() error {}", r); + } + + // This may not actually be needed, but it's a traditional courtesy: + std::this_thread::sleep_for(7ms); + + if (fdb_network_thread.joinable()) { + fdb_network_thread.join(); + } + } + + private: + friend struct ceph::libfdb::database; +}; + +} // namespace ceph::libfdb::detail + +class database final +{ + detail::database_system db_system; + + private: + std::shared_ptr db_handle; + + FDBDatabase *create_database_ptr(const std::filesystem::path cluster_file_path) { + FDBDatabase *fdbp = nullptr; + + if (fdb_error_t r = fdb_create_database(cluster_file_path.c_str(), &fdbp); 0 != r) { + throw libfdb_exception(r); + } + + return fdbp; + } + + FDBTransaction *create_transaction() { + FDBTransaction *txn_p = nullptr; + + if (fdb_error_t r = fdb_database_create_transaction(raw_handle(), &txn_p); 0 != r) { + throw libfdb_exception(r); + } + + return txn_p; + } + + public: + database(const std::filesystem::path cluster_file_path, const ceph::libfdb::database_options& db_opts, const network_options& network_opts) + : db_system(network_opts), + db_handle(create_database_ptr(cluster_file_path), &fdb_database_destroy) + { + detail::apply_options(db_opts, + [handle = raw_handle()](auto option_code, auto data, auto size) { + return fdb_database_set_option(handle, option_code, data, size); + }); + } + + database(const std::filesystem::path cluster_file_path, const ceph::libfdb::database_options& db_opts) + : database(cluster_file_path, db_opts, {}) + {} + + database(const ceph::libfdb::database_options& db_opts, const ceph::libfdb::network_options& net_opts) + : database("", db_opts, net_opts) + {} + + database(const ceph::libfdb::database_options& db_opts) + : database("", db_opts, {}) + {} + + database(const std::filesystem::path cluster_file_path) + : database(cluster_file_path, {}, {}) + {} + + database() + : database(std::filesystem::path {}, {}, {}) + {} + + public: + explicit operator bool() const noexcept { return nullptr != raw_handle(); } + + public: + FDBDatabase *raw_handle() const noexcept { return db_handle.get(); } + + private: + friend transaction; + +}; + +class transaction final +{ + database_handle dbh; + + std::unique_ptr txn_handle; + + private: + bool get_single_value_from_transaction(const std::span& key, std::invocable> auto&& write_output); + + public: + transaction(database_handle dbh_) + : dbh(dbh_), + txn_handle(dbh->create_transaction(), &fdb_transaction_destroy) + {} + + transaction(database_handle dbh_, const transaction_options& opts) + : transaction(dbh_) + { + detail::apply_options(opts, + [handle = raw_handle()](auto code, auto data, auto size) { + return fdb_transaction_set_option(handle, code, data, size); + }); + } + + public: + explicit operator bool() const noexcept { return dbh && nullptr != raw_handle(); } + + public: + FDBTransaction *raw_handle() const noexcept { return txn_handle.get(); } + + private: + void set(std::span k, std::span v) { + fdb_transaction_set(raw_handle(), + (const uint8_t*)k.data(), k.size(), + (const uint8_t*)v.data(), v.size()); + } + + // JFW: it's not as easy to wedge an output_range into here as it appears, perhaps + // needs to be revisited; I'm binding it to what's actually used in practice for now: + template + requires std::output_iterator> + bool get(const ceph::libfdb::select& key_range, OutIterT out_iter) { + return ceph::libfdb::detail::get_value_range_from_transaction(*this, key_range, out_iter); + } + + bool get(const std::span k, concepts::value_callback auto&& val_collector) { + return get_single_value_from_transaction(k, val_collector); + } + + void erase(std::span k) { + fdb_transaction_clear(raw_handle(), + (const std::uint8_t *)k.data(), k.size()); + } + + void erase(const ceph::libfdb::concepts::selector auto& key_range) { + fdb_transaction_clear_range(raw_handle(), + (const uint8_t *)key_range.begin_key.data(), key_range.begin_key.size(), + (const uint8_t *)key_range.end_key.data(), key_range.end_key.size()); + } + + bool key_exists(std::string_view k) { + return get_single_value_from_transaction(detail::as_fdb_span(k), [](auto) {}); + } + + bool commit(fdb_error_t *replay_error = nullptr); + void destroy() noexcept { txn_handle.reset(); } + + // As you can see, friends are used extensively to implement the public interface; it would be nice to come up + // with a strategy for making these "lists" a bit more managable, but for now it's what we have and it allows me ot + // keep these handles mostly-opaque (this has proven to be a good idea as I've a few times shuffled internal details + // with no disruption to the user interface surface): + private: + friend inline void set(transaction_handle, const std::string_view, const auto&, const commit_after_op); + friend inline void set(database_handle, std::string_view, const auto&); + friend inline void set(transaction_handle, std::string_view, const ceph::libfdb::concepts::stringview_convertible auto&, const commit_after_op); + friend inline void set(database_handle, std::string_view, const ceph::libfdb::concepts::stringview_convertible auto&); + + template + requires concepts::value_callback> || + concepts::value_output + friend inline bool get(ceph::libfdb::transaction_handle, + std::string_view, + OutputTargetOrFnT&&, + const commit_after_op); + friend inline bool get(ceph::libfdb::transaction_handle, const ceph::libfdb::select&, auto, const commit_after_op); + + friend inline void erase(ceph::libfdb::transaction_handle, std::string_view, const commit_after_op); + friend inline void erase(ceph::libfdb::transaction_handle, const ceph::libfdb::select&, const commit_after_op); + friend inline void erase(ceph::libfdb::database_handle, std::string_view); + friend inline void erase(ceph::libfdb::database_handle, const ceph::libfdb::select&); + + friend inline bool key_exists(transaction_handle txn, std::string_view k, const commit_after_op commit_after); + + friend inline bool commit(transaction_handle& txn); + friend inline fdb_error_t ceph::libfdb::detail::do_commit(transaction_handle& txn); + friend inline void ceph::libfdb::detail::transaction_set_kv_bytes(const transaction_handle&, + std::span, + std::span); + +}; + +namespace detail { + +// Since lambdas cannot be friend-functions, we use a named helper: +inline void transaction_set_kv_bytes(const transaction_handle& txn, + std::span k, + std::span v) +{ + txn->set(k, v); +} + +} // namespace detail + +inline bool ceph::libfdb::transaction::get_single_value_from_transaction(const std::span& key, std::invocable> auto&& write_output) +{ + const fdb_bool_t is_snapshot = false; + + detail::future_value fv(fdb_transaction_get( + raw_handle(), + (const uint8_t *)key.data(), key.size(), + is_snapshot)); + + if (fdb_error_t r = fdb_future_block_until_ready(fv.raw_handle()); 0 != r) { + throw libfdb_exception(r); + } + + fdb_bool_t key_was_found = false; + const uint8_t *out_buffer = nullptr; + int out_len = 0; + + if (fdb_error_t r = fdb_future_get_value(fv.raw_handle(), &key_was_found, &out_buffer, &out_len); 0 != r) { + throw libfdb_exception(r); + } + + // No errors, but no value was found: + if (0 == key_was_found) { + return false; + } + + write_output(std::span(out_buffer, out_len)); + + // The happy path is the simple path: + return true; +} + +[[nodiscard]] inline bool ceph::libfdb::transaction::commit(fdb_error_t *replay_error) +{ + if (nullptr != replay_error) { + *replay_error = 0; + } + + // We don't want to try to vivify for an "empty" commit: + if (!*this) + return false; + + detail::future_value fv(fdb_transaction_commit(raw_handle())); + + if (fdb_error_t r = fdb_future_block_until_ready(fv.raw_handle()); 0 != r) { + throw libfdb_exception(r); + } + + if (fdb_error_t r = fdb_future_get_error(fv.raw_handle()); 0 != r) { + detail::future_value ferror_result(fdb_transaction_on_error(raw_handle(), r)); + + if (0 != fdb_future_block_until_ready(ferror_result.raw_handle())) { + // In their example, they use the first error as the message source, so I will do that also: + throw libfdb_exception(r); + } + + if (0 != fdb_future_get_error(ferror_result.raw_handle())) { + // Destroy the futures AND be sure to invalidate the transaction-- according to the documentation, + // this must happen in the specified order (which /should/ currently match the dtor order, but I am + // not going to touch this any more right now): + fv.destroy(), ferror_result.destroy(), destroy(); + + // Again, use the original error: + throw libfdb_exception(r); + } + + // A false result means on_error() succeeded; the caller should replay the transaction: + if (nullptr != replay_error) { + *replay_error = r; + } + + return false; + } + + // Ok: + return true; +} + +} // namespace ceph::libfdb + +// Future-wrangling and tricky retry-handling: +namespace ceph::libfdb::detail { + +inline future_value block_until_ready(future_value&& fv) +{ + if (fdb_error_t r = fdb_future_block_until_ready(fv.raw_handle()); 0 != r) { + throw libfdb_exception(r); + } + + // Note that fdb_future_block_until_ready() does not by itself check for errors + // with the value; so, we need to do this separately: + fdb_error_t r = fdb_future_get_error(fv.raw_handle()); + + if (r) { + throw libfdb_exception(r); + } + + return fv; +} + +inline future_value wait_until_ready(future_value&& fv) +{ + if (fdb_error_t r = fdb_future_block_until_ready(fv.raw_handle()); 0 != r) { + throw libfdb_exception(r); + } + + return fv; +} + +template +requires std::invocable +inline future_value await_future_of(FnT&& fn, XS&& ...params) +{ + return block_until_ready( + std::invoke(std::forward(fn), std::forward(params)...)); +} + +template +requires std::output_iterator> +inline bool get_value_range_from_transaction(transaction& txn, const select& key_range, OutIterT out_iter) +{ + auto flattened = detail::generate_FDB_pairs(txn, key_range) | std::views::join; + std::ranges::transform(flattened, out_iter, to_decoded_kv_pair); + + return true; +} + +inline fdb_error_t value_from_db(future_value& fv, + std::invocable auto extract_fn) +{ + return std::invoke(extract_fn, fv); +} + +inline bool retry_after_error(ceph::libfdb::transaction_handle& txn, const fdb_error_t r) +{ + if (0 == r) { + return false; + } + + if (not fdb_error_predicate(FDB_ERROR_PREDICATE_RETRYABLE, r)) { + // Non-retryable errors cannot be repaired by on_error(): + throw ceph::libfdb::libfdb_exception(r); + } + + future_value on_error_future(fdb_transaction_on_error(txn->raw_handle(), r)); + + if (fdb_error_t on_error_r = fdb_future_block_until_ready(on_error_future.raw_handle()); 0 != on_error_r) { + throw ceph::libfdb::libfdb_exception(r); + } + + if (fdb_error_t on_error_r = fdb_future_get_error(on_error_future.raw_handle()); 0 != on_error_r) { + throw ceph::libfdb::libfdb_exception(r); + } + + return true; +} + +// Convert FDBKey array into something useful: +inline std::vector as_select_seq(const FDBKey* const xs, const int n) +{ + std::vector out; + + if (1 < n) { + out.reserve(static_cast(n - 1)); + } + + // Gather the flattened list into *overlapping* libfdb::select pairs: + for (const auto& dyad : std::span(xs, n) | std::views::slide(2)) { + const auto& fst = std::ranges::begin(dyad)[0]; + const auto& snd = std::ranges::begin(dyad)[1]; + + out.push_back({ + { (const char *)fst.key, static_cast(fst.key_length) }, + { (const char *)snd.key, static_cast(snd.key_length) } + }); + } + + return out; +} +// Finding a clear example both in the samples and in the documentation is not very easy. The +// statelessness of FDB requests bleeds into here with basically no hand-holding, so it's fairly subtle and +// not easy to see what to do (I'm still not sure I have it right), but note for instance that the call +// parameters have to change for subsequent reads. +inline future_value get_range_future_from_transaction(ceph::libfdb::transaction& txn, const ceph::libfdb::select& selection, int iteration) +{ + const auto& begin_key = selection.begin_key; + const auto& end_key = selection.end_key; + + // Hook for getting settings into here through the selector: + const auto& streaming_mode = selection.options.streaming_mode; + + // The documentation makes this stuff about as clear as mud... read VERY carefully + // when you fiddle with these: + int begin_or_eq = (1 == iteration) ? 0 : 1; + const int begin_offset = 1; + const int end_or_eq = 0; + const int end_offset = 1; + + // See validate_and_update_parameters() in fdb_c.cpp (FDB source) if greater clarity is needed on the meaning of some of these, it can be + // hard to deduce from the documentation: + // Returns an FDBKeyValueArray in the future: + + return future_value(fdb_transaction_get_range(txn.raw_handle(), + (const uint8_t *)begin_key.data(), begin_key.size(), // the reference-point key + begin_or_eq, // begin or eq + begin_offset, // begin offset + + (const uint8_t *)end_key.data(), end_key.size(), // the end selector key + end_or_eq, // end or eq + end_offset, // end offset (a shift AFTER end is matched) + + // How should results be grouped/chunked: + 0, // limit (0 == unlimited) + 0, // target bytes (0 == unlimited) + streaming_mode, // streaming mode (e.g.: FDB_STREAMING_MODE_WANT_ALL) + iteration, // iteration # (produced side effect) + + // Other options: + 0, // 0 unless this IS a snapshot read + 0 // reverse: should items come in reverse order? + )); +} + +inline std::vector locate_split_points( + ceph::libfdb::database_handle dbh, + ceph::libfdb::select selector, + const std::int64_t remote_chunk_size) +{ + using ceph::libfdb::detail::wait_until_ready; + + auto txn = ceph::libfdb::make_transaction(dbh); + + // We can do this without side-effects by combining some lambdas, but for now I'm satisfied if it works: + FDBKey const *keys = nullptr; + int nkeys = 0; + + for (bool should_retry = true; should_retry;) { + + auto fv = wait_until_ready(future_value(fdb_transaction_get_range_split_points( + txn->raw_handle(), + (const std::uint8_t *)selector.begin_key.data(), static_cast(selector.begin_key.length()), + (const std::uint8_t *)selector.end_key.data(), static_cast(selector.end_key.length()), + remote_chunk_size))); + + should_retry = retry_after_error(txn, value_from_db(fv, + [&keys, &nkeys](future_value& fv) { + return fdb_future_get_key_array(fv.raw_handle(), &keys, &nkeys); + })); + + if (not should_retry) { + return as_select_seq(keys, nkeys); + } + } + + return {}; +} + +// Generators: +// The returned memory should be copied immediately as its lifetime will end once the Future is destroyed: +// (This is pretty much why this is in the "detail" namespace for the moment.) +inline std::generator> generate_FDB_pairs(transaction& txn, select key_range) +{ + // As FDB is stateless, we need to track state somewhere: + int more_available = 1; + int out_count = 0; + int iteration = 1; // expected to be 1 when FDB is called in iterator mode + + const FDBKeyValue *out_kvs = nullptr; + + for (; more_available; iteration++) { + + auto fv = await_future_of([&]() { return get_range_future_from_transaction(txn, key_range, iteration); }); + + if (fdb_error_t r = fdb_future_get_keyvalue_array(fv.raw_handle(), &out_kvs, &out_count, &more_available); 0 != r) { + throw libfdb_exception(r); + } + + auto result = std::span(out_kvs, out_count); + + if (more_available) { + // Make the first part of the range for the new search equal to the last one from the old search: + const auto& last_key = result.back(); + key_range.begin_key = std::string_view((const char *)last_key.key, last_key.key_length); + } + + co_yield result; + } +} + +} // namespace ceph::libfdb::detail + + +#endif diff --git a/src/rgw/fdb/conversion.h b/src/rgw/fdb/conversion.h new file mode 100644 index 00000000000..eea6882fe22 --- /dev/null +++ b/src/rgw/fdb/conversion.h @@ -0,0 +1,129 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2025-2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * +*/ + +#ifndef CEPH_FDB_CONVERSION_H + #define CEPH_FDB_CONVERSION_H + +#include "base.h" + +#include "zpp_bits.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* This module is for converting internal types "owned" by FoundationDB. They've initially been implemented in +a conversion namespace with overloads, which is the same way that user conversions work, but especially with Concepts +this technique doesn't have to be used-- it is likely possible to avoid default construction and possible extra copies. +Since I'm building a prototype right now, it's more important to me on this pass to make things understandable and +clear, because past experience informs me that it's better to have a clear understanding of what the goals of "to" +and "from" versions actually are in relationship to user-level types than to have every nanosecond of performance +be available on day one. + +The target of "to" conversions is not a USER type, but rather the FUNCTIONS provided inside of libfdb-- users +should NOT see the output of these or have to handle them outside of tests or edge-cases (and even then, I doubt it's +needed, though I won't work hard to stop it). + +I'm hoping that later down the line I can sit and spend more time with this-- it would be nice, for example, if we could +use memory provided by the caller. + +Additionally, this mechanism is mostly obviated by forwarding the work to zpp_bits, but keeping it here provides an additional +hook "ahead" of that library, and indeed eliminates any actual dependency on it-- we use it to smooth out a few areas where +zpp_bits is designed for serialization ONLY rather than also playing nice with some conversions, especially those where the +input and output sizes of an array may not match. +*/ +namespace ceph::libfdb::to { + +inline auto convert(const auto& from, std::vector& out_data) -> std::span +{ + out_data.clear(); + + zpp::bits::out out(out_data); + + // zpp::bits won't write a size if we start with a fixed size array: + // (see dynamic_extent): + if constexpr (std::is_array_v) { + out(std::span(from, std::size(from))).or_throw(); + + return out_data; + } + + out(from).or_throw(); + + return out_data; +} + +inline auto convert(const auto& from) -> std::vector +{ + std::vector out_data; + convert(from, out_data); + + return out_data; +} + +} // namespace ceph::libfdb::to + +/* Map from FDB inputs from FDB TYPE to CONCRETE (i.e. copyable) userland types. Do NOT add +non-FDB input sources here (or any non-matching user output sources). Do NOT add +non-owning targets, lest Antevorda be angered!: */ +namespace ceph::libfdb::from { + +inline void convert(const std::span& from, auto& to) +{ + zpp::bits::in zpp_in(from); + zpp_in(to).or_throw(); +} + +template OutputFunction> +inline void convert(const std::span& in, OutputFunction& fn) +{ + fn((const char *)in.data(), in.size()); +} + +} // namespace ceph::libfdb::from + +namespace ceph::libfdb::detail { + +template +inline std::pair to_decoded_kv_pair(const FDBKeyValue& kv) +{ + std::pair r; + + r.first.assign((const char *)kv.key, static_cast(kv.key_length)); + + try + { + ceph::libfdb::from::convert(std::span(kv.value, kv.value_length), r.second); + } + catch (const std::system_error& e) { + // Translate from underlying (e.g. zpp_bits) conversion error into the right type: + // This is a bit bound to zpp_bits for the moment, but there's not a more direct way to distinguish this + // from a different system_error. We could do that, by using zpp_bits' non-throwing modes and throwing a + // special type, but this will do for now. + throw ceph::libfdb::libfdb_exception(e.what()); + } + + return r; +} + +} // namespace ceph::libfdb::detail + +#endif diff --git a/src/rgw/fdb/fdb.h b/src/rgw/fdb/fdb.h new file mode 100644 index 00000000000..0165a6c96d1 --- /dev/null +++ b/src/rgw/fdb/fdb.h @@ -0,0 +1,23 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- +// vim: ts=8 sw=2 smarttab ft=cpp + +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2025-2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * +*/ + +#ifndef CEPH_RGW_FDB_H + #define CEPH_RGW_FDB_H + +#include "base.h" +#include "interface.h" +#include "conversion.h" + +#endif diff --git a/src/rgw/fdb/interface.h b/src/rgw/fdb/interface.h new file mode 100644 index 00000000000..d5677e0f18f --- /dev/null +++ b/src/rgw/fdb/interface.h @@ -0,0 +1,657 @@ +// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:t -*- // vim: ts=8 sw=2 smarttab ft=cpp +/* + * Ceph - scalable distributed file system + * + * Copyright (C) 2025-2026 International Business Machines Corp. (IBM) + * + * This is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License version 2.1, as published by the Free Software + * Foundation. See file COPYING. + * +*/ + +#ifndef CEPH_FDB_BINDINGS_H + #define CEPH_FDB_BINDINGS_H + +#include "base.h" +#include "conversion.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ceph::libfdb { + +/* This needs to be called once and only once during application lifetime, +when no more FoundationDB calls will be made: */ +inline void shutdown_libfdb() +{ + ceph::libfdb::detail::database_system::shutdown_fdb(); +} + +// By default, libfdb will in turn use FDB's own defaults (see FDB docs): +inline database_handle create_database() +{ + return std::make_shared(); +} + +inline database_handle create_database(const std::filesystem::path dbfile) +{ + return std::make_shared(dbfile); +} + +inline database_handle create_database(const std::filesystem::path dbfile, + const database_options& dbopts, + const network_options& netopts) +{ + return std::make_shared(dbfile, dbopts, netopts); +} + +inline database_handle create_database(const database_options& dbopts, + const network_options& netopts) +{ + return std::make_shared(dbopts, netopts); +} + +inline database_handle create_database(const database_options& opts) +{ + return std::make_shared(opts, network_options{}); +} + +inline database_handle create_database(const std::filesystem::path dbfile, + const database_options& dbopts) +{ + return std::make_shared(dbfile, dbopts, network_options{}); +} + +inline transaction_handle make_transaction(database_handle dbh) +{ + return std::make_shared(dbh); +} + +inline transaction_handle make_transaction(database_handle dbh, const transaction_options& opts) +{ + return std::make_shared(dbh, opts); +} + +// Note: only rarely is a direct call to this needed. You can use transactors or pass database_handles +// to get automagic. +// Note: after a transaction is committed, it cannot be used again; but right now, that is NOT +// an error with respect to the object. So, don't do operations on the object after you've committed +// it or the behavior could be surprising. +// On false, the client should retry the transaction: +[[nodiscard]] inline bool commit(transaction_handle& txn) +{ + return txn->commit(); +} + +} // namespace ceph::libfdb + +namespace ceph::libfdb::detail { + +// Forward declarations: +template +using transaction_invocation_result_t = + std::invoke_result_t; + +template +concept supported_transaction_invocation = + concepts::supported_invocation_result>; + +template +using operation_result_t = + std::conditional_t>, + void, + std::remove_cvref_t>>; + +template +struct value_collector_t final +{ + OutValuesT& out_values; + + void operator()(std::span out_data) const; +}; + +template +auto value_collector(OutValuesT& out_values) -> value_collector_t; + +template +auto maybe_retry(transaction_handle txn, FnT&& fn) -> operation_result_t; + +template +auto maybe_commit(transaction_handle txn, const commit_after_op commit_after, FnT&& fn) + -> operation_result_t; + +template +auto commit_noreplay(transaction_handle txn, const commit_after_op commit_after, FnT&& fn) + -> operation_result_t; + +} // namespace ceph::libfdb::detail + +namespace ceph::libfdb { + +inline void set(transaction_handle txn, + std::string_view k, const auto& v, + const commit_after_op commit_after) +{ + return detail::commit_noreplay(txn, commit_after, + [key = detail::as_fdb_span(k), &v](const transaction_handle& txn) { + return txn->set(key, ceph::libfdb::to::convert(v)); + }); +} + +// If someone gives us an explicit transaction handle, they almost certainly don't want to commit +// it (though they can always specify otherwise): +inline void set(transaction_handle txn, std::string_view k, const auto& v) +{ + return set(txn, k, v, commit_after_op::no_commit); +} + +// ...conversely, with a database handle given, we can assume they DO want to auto-commit: +inline void set(database_handle dbh, + std::string_view k, const auto& v) +{ + return detail::maybe_commit(make_transaction(dbh), commit_after_op::commit, + [key = detail::as_fdb_span(k), &v](const transaction_handle& txn) { + return txn->set(key, ceph::libfdb::to::convert(v)); + }); +} + +template