diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index ef519c9305..6738076873 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -108,11 +108,13 @@ __all__ = [ "SendMessage", "SendMessagesConfirmation", "SendMessagesResponse", + "SnapshotCompression", "Stats", "Stream", "StreamDetails", "StreamOptions", "StreamPermissions", + "SystemSnapshotType", "TcpConfig", "TcpReconnectionConfig", "TokenInfo", @@ -1260,6 +1262,36 @@ class IggyClient: authenticated, the user lacks the permission, or the request fails. """ + def snapshot( + self, + snapshot_compression: SnapshotCompression, + snapshot_types: typing.Sequence[SystemSnapshotType], + ) -> collections.abc.Awaitable[bytes]: + r""" + Capture a diagnostic snapshot of the server as a compressed ZIP archive. + + Requires an authenticated session whose user holds the `manage_servers` + global permission. + + Args: + snapshot_compression: Archive compression method as a + `SnapshotCompression` enum. + snapshot_types: Data to include in the snapshot as a list of + `SystemSnapshotType` enums. `SystemSnapshotType.All` cannot be + combined with any other snapshot type. + + Returns: + An awaitable that resolves to the ZIP archive `bytes`. + + Raises: + TypeError: If `snapshot_compression` is not a `SnapshotCompression` + or `snapshot_types` contains elements other than `SystemSnapshotType`. + ValueError: If `snapshot_types` is empty. + RuntimeError: If the client is not connected, the session is not + authenticated, the user lacks the permission, `SystemSnapshotType.All` is + combined with another snapshot type, the 30-second binary + operation deadline expires, or the request fails. + """ def describe_options( self, scope: builtins.str ) -> collections.abc.Awaitable[list[OptionSpec]]: @@ -4121,6 +4153,37 @@ class SendMessagesResponse: receipts for persisted topics. """ +@typing.final +class SnapshotCompression: + r""" + Compression method for server snapshots. + """ + + Stored: typing.ClassVar[SnapshotCompression] + r""" + Store the file as is without compression. + """ + Deflated: typing.ClassVar[SnapshotCompression] + r""" + Compress the file using Deflate. + """ + Bzip2: typing.ClassVar[SnapshotCompression] + r""" + Compress the file using BZIP2. + """ + Zstd: typing.ClassVar[SnapshotCompression] + r""" + Compress the file using ZStandard. + """ + Lzma: typing.ClassVar[SnapshotCompression] + r""" + Compress the file using LZMA. + """ + Xz: typing.ClassVar[SnapshotCompression] + r""" + Compress the file using XZ. + """ + @typing.final class Stats: r""" @@ -4576,6 +4639,41 @@ class StreamPermissions: treated as `None`. """ +@typing.final +class SystemSnapshotType: + r""" + Type of system data to include in a snapshot. + """ + + FilesystemOverview: typing.ClassVar[SystemSnapshotType] + r""" + Overview of the filesystem. + """ + ProcessList: typing.ClassVar[SystemSnapshotType] + r""" + List of currently running processes. + """ + ResourceUsage: typing.ClassVar[SystemSnapshotType] + r""" + Resource usage statistics of the system. + """ + Test: typing.ClassVar[SystemSnapshotType] + r""" + Test snapshot type for development purposes. + """ + ServerLogs: typing.ClassVar[SystemSnapshotType] + r""" + Server logs. + """ + ServerConfig: typing.ClassVar[SystemSnapshotType] + r""" + Server configuration. + """ + All: typing.ClassVar[SystemSnapshotType] + r""" + Everything. + """ + @typing.final class TcpConfig: r""" diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 8faa778ccd..c7e90cacf1 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -52,6 +52,9 @@ use crate::producer::{IggyProducer, ProducerMode, u32_param as producer_u32_para use crate::receive_message::{PartitionContext, PollingStrategy, ReceiveMessage}; use crate::resource_options::{OptionKey, StreamOptions, TopicOptions, UserOptions}; use crate::send_message::{SendMessage, SendMessagesResponse as PySendMessagesResponse}; +use crate::snapshot::{ + SnapshotCompression as PySnapshotCompression, SystemSnapshotType as PySystemSnapshotType, +}; use crate::stats::Stats as PyStats; use crate::stream::{Stream, StreamDetails}; use crate::topic::{IggyExpiry, MaxTopicSize, Topic, TopicDetails}; @@ -315,6 +318,59 @@ impl IggyClient { }) } + /// Capture a diagnostic snapshot of the server as a compressed ZIP archive. + /// + /// Requires an authenticated session whose user holds the `manage_servers` + /// global permission. + /// + /// Args: + /// snapshot_compression: Archive compression method as a + /// `SnapshotCompression` enum. + /// snapshot_types: Data to include in the snapshot as a list of + /// `SystemSnapshotType` enums. `SystemSnapshotType.All` cannot be + /// combined with any other snapshot type. + /// + /// Returns: + /// An awaitable that resolves to the ZIP archive `bytes`. + /// + /// Raises: + /// TypeError: If `snapshot_compression` is not a `SnapshotCompression` + /// or `snapshot_types` contains elements other than `SystemSnapshotType`. + /// ValueError: If `snapshot_types` is empty. + /// RuntimeError: If the client is not connected, the session is not + /// authenticated, the user lacks the permission, `SystemSnapshotType.All` is + /// combined with another snapshot type, the 30-second binary + /// operation deadline expires, or the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[bytes]", imports=("collections.abc")))] + fn snapshot<'a>( + &self, + py: Python<'a>, + snapshot_compression: PySnapshotCompression, + snapshot_types: Vec, + ) -> PyResult> { + if snapshot_types.is_empty() { + return Err(PyValueError::new_err("snapshot_types must not be empty")); + } + + let mut parsed_types = Vec::with_capacity(snapshot_types.len()); + for snapshot_type in snapshot_types { + let rust_type = SystemSnapshotType::from(snapshot_type); + if !parsed_types.contains(&rust_type) { + parsed_types.push(rust_type); + } + } + + let inner = self.inner.clone(); + let compression = SnapshotCompression::from(snapshot_compression); + future_into_py(py, async move { + let snapshot = inner + .snapshot(compression, parsed_types) + .await + .map_err(to_runtime_error)?; + Ok(Python::attach(|py| PyBytes::new(py, &snapshot.0).unbind())) + }) + } + /// Describe the option catalog for a resource scope. /// /// This is the discovery surface for the `options` argument on diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index 9ed4a4f6ce..fed4471a73 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -29,6 +29,7 @@ mod producer; mod receive_message; mod resource_options; mod send_message; +mod snapshot; mod stats; mod stream; mod topic; @@ -70,6 +71,7 @@ use resource_options::{ encryption_key_fingerprint, }; use send_message::{SendMessage, SendMessagesConfirmation, SendMessagesResponse}; +use snapshot::{SnapshotCompression, SystemSnapshotType}; use stats::{CacheMetrics, CacheMetricsKey, Stats}; use stream::{Stream, StreamDetails}; use topic::{IggyExpiry, MaxTopicSize, Partition, Topic, TopicDetails}; @@ -146,6 +148,8 @@ fn apache_iggy(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/foreign/python/src/snapshot.rs b/foreign/python/src/snapshot.rs new file mode 100644 index 0000000000..725fc4f968 --- /dev/null +++ b/foreign/python/src/snapshot.rs @@ -0,0 +1,116 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use iggy::prelude::{ + SnapshotCompression as RustSnapshotCompression, SystemSnapshotType as RustSystemSnapshotType, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::gen_stub_pyclass_enum; + +/// Compression method for server snapshots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[gen_stub_pyclass_enum] +#[pyclass(eq, from_py_object)] +pub enum SnapshotCompression { + /// Store the file as is without compression. + Stored, + /// Compress the file using Deflate. + Deflated, + /// Compress the file using BZIP2. + Bzip2, + /// Compress the file using ZStandard. + Zstd, + /// Compress the file using LZMA. + Lzma, + /// Compress the file using XZ. + Xz, +} + +impl From for RustSnapshotCompression { + fn from(compression: SnapshotCompression) -> Self { + match compression { + SnapshotCompression::Stored => RustSnapshotCompression::Stored, + SnapshotCompression::Deflated => RustSnapshotCompression::Deflated, + SnapshotCompression::Bzip2 => RustSnapshotCompression::Bzip2, + SnapshotCompression::Zstd => RustSnapshotCompression::Zstd, + SnapshotCompression::Lzma => RustSnapshotCompression::Lzma, + SnapshotCompression::Xz => RustSnapshotCompression::Xz, + } + } +} + +impl From for SnapshotCompression { + fn from(compression: RustSnapshotCompression) -> Self { + match compression { + RustSnapshotCompression::Stored => SnapshotCompression::Stored, + RustSnapshotCompression::Deflated => SnapshotCompression::Deflated, + RustSnapshotCompression::Bzip2 => SnapshotCompression::Bzip2, + RustSnapshotCompression::Zstd => SnapshotCompression::Zstd, + RustSnapshotCompression::Lzma => SnapshotCompression::Lzma, + RustSnapshotCompression::Xz => SnapshotCompression::Xz, + } + } +} + +/// Type of system data to include in a snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[gen_stub_pyclass_enum] +#[pyclass(eq, from_py_object)] +pub enum SystemSnapshotType { + /// Overview of the filesystem. + FilesystemOverview, + /// List of currently running processes. + ProcessList, + /// Resource usage statistics of the system. + ResourceUsage, + /// Test snapshot type for development purposes. + Test, + /// Server logs. + ServerLogs, + /// Server configuration. + ServerConfig, + /// Everything. + All, +} + +impl From for RustSystemSnapshotType { + fn from(snapshot_type: SystemSnapshotType) -> Self { + match snapshot_type { + SystemSnapshotType::FilesystemOverview => RustSystemSnapshotType::FilesystemOverview, + SystemSnapshotType::ProcessList => RustSystemSnapshotType::ProcessList, + SystemSnapshotType::ResourceUsage => RustSystemSnapshotType::ResourceUsage, + SystemSnapshotType::Test => RustSystemSnapshotType::Test, + SystemSnapshotType::ServerLogs => RustSystemSnapshotType::ServerLogs, + SystemSnapshotType::ServerConfig => RustSystemSnapshotType::ServerConfig, + SystemSnapshotType::All => RustSystemSnapshotType::All, + } + } +} + +impl From for SystemSnapshotType { + fn from(snapshot_type: RustSystemSnapshotType) -> Self { + match snapshot_type { + RustSystemSnapshotType::FilesystemOverview => SystemSnapshotType::FilesystemOverview, + RustSystemSnapshotType::ProcessList => SystemSnapshotType::ProcessList, + RustSystemSnapshotType::ResourceUsage => SystemSnapshotType::ResourceUsage, + RustSystemSnapshotType::Test => SystemSnapshotType::Test, + RustSystemSnapshotType::ServerLogs => SystemSnapshotType::ServerLogs, + RustSystemSnapshotType::ServerConfig => SystemSnapshotType::ServerConfig, + RustSystemSnapshotType::All => SystemSnapshotType::All, + } + } +} diff --git a/foreign/python/tests/test_snapshot.py b/foreign/python/tests/test_snapshot.py new file mode 100644 index 0000000000..669fac4d79 --- /dev/null +++ b/foreign/python/tests/test_snapshot.py @@ -0,0 +1,88 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import io +import zipfile + +import pytest + +from apache_iggy import ( + IggyClient, + SnapshotCompression, + SystemSnapshotType, +) + + +class TestSnapshot: + """Test server snapshot retrieval.""" + + @pytest.mark.asyncio + async def test_snapshot_returns_zip_bytes(self, iggy_client: IggyClient): + """Capturing a snapshot returns a non-empty ZIP archive as bytes.""" + data = await iggy_client.snapshot( + SnapshotCompression.Deflated, [SystemSnapshotType.All] + ) + + assert isinstance(data, bytes) + assert len(data) > 0 + assert zipfile.is_zipfile(io.BytesIO(data)) + + @pytest.mark.unit + @pytest.mark.parametrize("invalid_compression", ["deflated", "", 123, None]) + def test_snapshot_rejects_invalid_snapshot_compression(self, invalid_compression): + """Passing non-enum snapshot_compression raises TypeError before sending.""" + client = IggyClient("127.0.0.1:1") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + client.snapshot(invalid_compression, [SystemSnapshotType.Test]) + + @pytest.mark.unit + def test_snapshot_rejects_invalid_snapshot_type(self): + """An unknown or non-enum snapshot type raises TypeError before sending.""" + client = IggyClient("127.0.0.1:1") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + client.snapshot(SnapshotCompression.Deflated, ["test"]) + + @pytest.mark.unit + def test_snapshot_rejects_empty_snapshot_types(self): + """An empty snapshot_types list raises ValueError before sending.""" + client = IggyClient("127.0.0.1:1") + with pytest.raises(ValueError): + client.snapshot(SnapshotCompression.Deflated, []) + + @pytest.mark.asyncio + async def test_snapshot_rejects_all_combined_with_other_type( + self, iggy_client: IggyClient + ): + """Combining 'all' with another snapshot type is rejected.""" + with pytest.raises(RuntimeError, match="(?i)invalid command"): + await iggy_client.snapshot( + SnapshotCompression.Deflated, + [SystemSnapshotType.All, SystemSnapshotType.Test], + ) + + @pytest.mark.asyncio + async def test_snapshot_deduplicates_snapshot_types(self, iggy_client: IggyClient): + """Duplicate snapshot types are deduplicated before encoding.""" + data = await iggy_client.snapshot( + SnapshotCompression.Deflated, [SystemSnapshotType.Test] * 255 + ) + + assert isinstance(data, bytes) + assert len(data) > 0 + assert zipfile.is_zipfile(io.BytesIO(data))