From f92e7e5098d000904e5348e287f41f00f97d9042 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:33:48 +0200 Subject: [PATCH 01/19] refactor(aggregator): centralize timeout constants for IPFS queries To allow easy re-use of them in other queries --- .../src/tools/kubo_rpc_client/api.rs | 10 ++++++-- .../src/tools/kubo_rpc_client/mod.rs | 2 +- .../tools/kubo_rpc_client/query/ipfs_add.rs | 25 +++++++++---------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs index a2334af585c..25a4f87f023 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/api.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/api.rs @@ -9,6 +9,12 @@ use mithril_common::{StdError, StdResult}; use crate::tools::url_sanitizer::SanitizedUrlWithTrailingSlash; +/// Default timeout for the RPC request. +pub const DEFAULT_TIMEOUT_SECS: u64 = 10; + +/// Timeout for slow queries (large uploads, DHT publication). +pub const TIMEOUT_FOR_SLOW_QUERY_SECS: u64 = 3 * 60; + /// Trait for defining RPC queries to the Kubo IPFS node. #[async_trait::async_trait] pub trait KuboRpcQuery: Sync { @@ -30,9 +36,9 @@ pub trait KuboRpcQuery: Sync { Ok(request_builder) } - /// Timeout for the RPC request, defaults to 10 seconds. + /// Timeout for the RPC request, defaults to [DEFAULT_TIMEOUT_SECS] seconds. fn timeout(&self) -> Duration { - Duration::from_secs(10) + Duration::from_secs(DEFAULT_TIMEOUT_SECS) } /// Handles a successful RPC response and converts it to the expected response type. diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs index ce7ff234def..4876be6cdaa 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/mod.rs @@ -2,7 +2,7 @@ mod api; mod path; pub mod query; -pub use api::{KuboRpcClient, KuboRpcQuery}; +pub use api::{DEFAULT_TIMEOUT_SECS, KuboRpcClient, KuboRpcQuery, TIMEOUT_FOR_SLOW_QUERY_SECS}; pub use path::IpfsMfsDirPath; #[cfg(test)] diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs index 60ad7b78312..875e0b9b493 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_add.rs @@ -7,7 +7,9 @@ use serde::Deserialize; use mithril_common::StdResult; -use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcQuery}; +use crate::tools::kubo_rpc_client::{ + DEFAULT_TIMEOUT_SECS, IpfsMfsDirPath, KuboRpcQuery, TIMEOUT_FOR_SLOW_QUERY_SECS, +}; /// Query to add a file to IPFS via the Kubo RPC API. /// @@ -30,9 +32,6 @@ pub struct IpfsAddResponse { } impl IpfsAddQuery { - const BASE_TIMEOUT_SECS: u64 = 10; - const MAX_TIMEOUT_SECS: u64 = 3 * 60; - /// Create a query that will add the given file to IPFS. #[cfg(test)] pub fn new>(file_path: P) -> Self { @@ -77,12 +76,12 @@ impl IpfsAddQuery { const BYTES_PER_MIB: u64 = 1024 * 1024; match file_size_in_bytes { - None => Duration::from_secs(Self::MAX_TIMEOUT_SECS), + None => Duration::from_secs(TIMEOUT_FOR_SLOW_QUERY_SECS), Some(size_in_bytes) => { let file_size_mib = size_in_bytes.div_ceil(BYTES_PER_MIB); - let timeout_secs = Self::BASE_TIMEOUT_SECS + let timeout_secs = DEFAULT_TIMEOUT_SECS .saturating_add(file_size_mib) - .min(Self::MAX_TIMEOUT_SECS); + .min(TIMEOUT_FOR_SLOW_QUERY_SECS); Duration::from_secs(timeout_secs) } @@ -276,7 +275,7 @@ mod tests { #[test] fn timeout_duration_adds_one_second_for_each_started_mib() { let cases = [ - (0, IpfsAddQuery::BASE_TIMEOUT_SECS), + (0, DEFAULT_TIMEOUT_SECS), (1, 11), (MIB, 11), (MIB + 1, 12), @@ -294,13 +293,13 @@ mod tests { } #[test] - fn timeout_duration_is_capped_at_three_minutes() { + fn timeout_duration_is_capped_at_slow_query_timeout() { let cases = [ (169 * MIB, 179), - (169 * MIB + 1, IpfsAddQuery::MAX_TIMEOUT_SECS), - (170 * MIB, IpfsAddQuery::MAX_TIMEOUT_SECS), - (171 * MIB, IpfsAddQuery::MAX_TIMEOUT_SECS), - (u64::MAX, IpfsAddQuery::MAX_TIMEOUT_SECS), + (169 * MIB + 1, TIMEOUT_FOR_SLOW_QUERY_SECS), + (170 * MIB, TIMEOUT_FOR_SLOW_QUERY_SECS), + (171 * MIB, TIMEOUT_FOR_SLOW_QUERY_SECS), + (u64::MAX, TIMEOUT_FOR_SLOW_QUERY_SECS), ]; for (file_size, expected_timeout_secs) in cases { From 2fe2f6bb2b06d34de943d8c7b7a42c6142b7fc8e Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:39:17 +0200 Subject: [PATCH 02/19] feat(aggregator): add IPNS publish query --- .../query/ipfs_name_publish.rs | 117 ++++++++++++++++++ .../src/tools/kubo_rpc_client/query/mod.rs | 2 + 2 files changed, 119 insertions(+) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs new file mode 100644 index 00000000000..8df779b4b38 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use reqwest::{RequestBuilder, Response}; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::{KuboRpcQuery, TIMEOUT_FOR_SLOW_QUERY_SECS}; + +/// Query to publish an IPFS object to IPNS +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-name-publish +#[derive(Debug)] +pub struct IpfsNamePublishQuery { + ipfs_object_path: String, + ipfs_key: String, +} + +impl IpfsNamePublishQuery { + /// Lifetime in hours of the IPNS record in the DHT (using kubo default: 48 hours). + const BASE_LIFETIME_HOURS: u64 = 48; + /// Cache TTL in seconds of the IPNS record in the Kubo node (using kubo default: 5 minutes). + const BASE_TTL_SECONDS: u64 = 60 * 5; + + /// Create a query that will publish the given IPFS object to IPNS. + pub fn publish_to_ipns, K: Into>( + ipfs_object_path: P, + ipfs_key: K, + ) -> Self { + Self { + ipfs_object_path: ipfs_object_path.into(), + ipfs_key: ipfs_key.into(), + } + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsNamePublishQuery { + type Response = (); + + fn route(&self) -> String { + "api/v0/name/publish".to_string() + } + + async fn configure_request( + &self, + request_builder: RequestBuilder, + ) -> StdResult { + Ok(request_builder.query(&[ + ("arg", &self.ipfs_object_path), + ("key", &self.ipfs_key), + ("lifetime", &format!("{}h", Self::BASE_LIFETIME_HOURS)), + ("ttl", &format!("{}s", Self::BASE_TTL_SECONDS)), + ])) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(TIMEOUT_FOR_SLOW_QUERY_SECS) + } + + async fn handle_success(&self, _response: Response) -> StdResult { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn succeeds_when_server_returns_200() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/name/publish") + .query_param( + "arg", + "/ipfs/QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR", + ) + .query_param("key", "my-key-name") + .query_param("lifetime", "48h") + .query_param("ttl", "300s"); + then.status(200); + }); + + client + .send(IpfsNamePublishQuery::publish_to_ipns( + "/ipfs/QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR", + "my-key-name", + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn return_error_if_request_fails_with_other_message() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/name/publish"); + then.status(500).json_body( + serde_json::json!({"Message":"no key by the given name was found","Code":0,"Type":"error"}), + ); + }); + + let err = client + .send(IpfsNamePublishQuery::publish_to_ipns( + "/ipfs/QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR", + "my-key-name", + )) + .await + .unwrap_err(); + assert!(err.to_string().contains("no key by the given name was found")); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs index 42e3dfc97a8..522d0b3a71c 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs @@ -2,8 +2,10 @@ mod ipfs_add; mod ipfs_files_ls; mod ipfs_files_mkdir; mod ipfs_files_stat; +mod ipfs_name_publish; pub use ipfs_add::*; pub use ipfs_files_ls::*; pub use ipfs_files_mkdir::*; pub use ipfs_files_stat::*; +pub use ipfs_name_publish::*; From 4a9bed404be49e750ca0855a54d51562e6996d54 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:26:27 +0200 Subject: [PATCH 03/19] feat(aggregator): add IPNS directory publishing with fallback logging - Introduced optional `ipns_key_name` to `IpfsUploader`, it enable IPNS publishing if set - Implemented IPNS publishing for directories after batch upload. - Added warning logs for failed IPNS publishing without interrupting operations. --- .../builder/protocol/artifacts.rs | 1 + .../src/file_uploaders/ipfs_uploader.rs | 115 +++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index ccc24096cc7..6179a9f3016 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -282,6 +282,7 @@ impl DependenciesBuilder { Ok(IpfsUploader::new( Arc::new(rpc_api_client), IpfsMfsDirPath::from(ipfs_rpc_config.mfs_folder_name), + None, FileUploadRetryPolicy::default(), &self.root_logger(), )) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 4407670bc3b..041882610d2 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; -use slog::{Logger, trace}; +use slog::{Logger, trace, warn}; use mithril_common::StdResult; use mithril_common::entities::FileUri; @@ -13,7 +13,7 @@ use crate::FileUploader; use crate::file_uploaders::FileUploadRetryPolicy; use crate::file_uploaders::interface::retry; use crate::tools::kubo_rpc_client::query::{ - IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, + IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, IpfsNamePublishQuery, }; use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; @@ -24,6 +24,7 @@ pub type IpfsCid = String; pub struct IpfsUploader { rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, + ipns_key_name: Option, retry_policy: FileUploadRetryPolicy, logger: Logger, } @@ -33,12 +34,14 @@ impl IpfsUploader { pub fn new( rpc_client: Arc, ipfs_dir_path: IpfsMfsDirPath, + ipns_key_name: Option, retry_policy: FileUploadRetryPolicy, logger: &Logger, ) -> Self { Self { rpc_client, ipfs_dir_path, + ipns_key_name, retry_policy, logger: logger.new_with_component_name::(), } @@ -84,7 +87,11 @@ impl IpfsUploader { .await?; } - self.get_current_directory_cid().await + let dir_cid = self.get_current_directory_cid().await?; + + self.publish_directory_to_ipns(&dir_cid).await; + + Ok(dir_cid) } /// Get the current directory CID, reflecting the latest state of the directory @@ -146,6 +153,24 @@ impl IpfsUploader { Ok(FileUri(cid)) } + + async fn publish_directory_to_ipns(&self, dir_cid: &IpfsCid) { + if let Some(key) = &self.ipns_key_name { + trace!(self.logger, "Publishing dir to IPNS"; "dir_cid" => dir_cid, "key_name" => key); + + match self.rpc_client.publish_dir_to_ipns(dir_cid, key).await { + Ok(()) => { + trace!(self.logger, "Directory published to IPNS"; "dir_cid" => dir_cid, "key_name" => key); + } + Err(err) => { + warn!( + self.logger, "Failed to publish directory to an IPNS record"; + "err" => ?err, "dir_cid" => dir_cid, "key_name" => key + ); + } + } + } + } } #[async_trait::async_trait] @@ -181,6 +206,9 @@ pub trait IpfsBackendUploader: Sync + Send { mfs_dir_path: &IpfsMfsDirPath, file_path: &Path, ) -> StdResult>; + + /// Publish a directory to IPNS using a given key + async fn publish_dir_to_ipns(&self, dir_cid: &IpfsCid, key_name: &str) -> StdResult<()>; } #[async_trait::async_trait] @@ -221,6 +249,12 @@ impl IpfsBackendUploader for KuboRpcClient { .await?; Ok(stat.map(|stat| stat.hash)) } + + async fn publish_dir_to_ipns(&self, dir_cid: &IpfsCid, key_name: &str) -> StdResult<()> { + let object_path = format!("/ipfs/{dir_cid}"); + self.send(IpfsNamePublishQuery::publish_to_ipns(object_path, key_name)) + .await + } } #[cfg(test)] @@ -241,10 +275,21 @@ mod tests { Self::new( MockBuilder::configure(mock_config), mfs_dir.into(), + None, FileUploadRetryPolicy::never(), &TestLogger::stdout(), ) } + + fn with_ipns_key_name(mut self, key_name: &str) -> Self { + self.ipns_key_name = Some(key_name.to_string()); + self + } + + fn with_logger(mut self, logger: Logger) -> Self { + self.logger = logger; + self + } } #[tokio::test] @@ -335,6 +380,7 @@ mod tests { .with(eq(IpfsMfsDirPath::from(MFS_DIR))) .return_once(|_| Ok("directory-cid".to_string())) .once(); + mock.expect_publish_dir_to_ipns().never(); }); let directory_cid = uploader @@ -349,6 +395,44 @@ mod tests { assert_eq!("directory-cid", directory_cid); } + #[tokio::test] + async fn publish_directory_cid_to_ipns_using_key_name_if_configured() { + let uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { + mock.expect_create_dir() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok(())) + .once(); + mock.expect_list_directory_files() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(move |_| Ok(HashSet::new())) + .once(); + mock.expect_file_exists().never(); + mock.expect_upload_file() + .with( + eq(PathBuf::from("/local/new-file-1.txt")), + eq(IpfsMfsDirPath::from(MFS_DIR)), + ) + .return_once(|_, _| Ok("file-1-cid".to_string())) + .once(); + mock.expect_get_dir_cid() + .with(eq(IpfsMfsDirPath::from(MFS_DIR))) + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + mock.expect_publish_dir_to_ipns() + .with(eq("directory-cid".to_string()), eq("test-key-name")) + .return_once(|_, _| Ok(())) + .once(); + }) + .with_ipns_key_name("test-key-name"); + + let directory_cid = uploader + .batch_upload_to_dir(&[PathBuf::from("/local/new-file-1.txt")]) + .await + .unwrap(); + + assert_eq!("directory-cid", directory_cid); + } + #[tokio::test] async fn support_retry() { let mut uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { @@ -535,5 +619,30 @@ mod tests { .await .expect_err("directory CID retrieval failure should be returned"); } + + #[tokio::test] + async fn log_error_as_warning_and_continue_without_failing_if_publishing_to_ipns_fails() { + let (logger, log_inspector) = TestLogger::memory(); + let uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .return_once(|_| Ok(HashSet::new())) + .once(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid() + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + mock.expect_publish_dir_to_ipns() + .return_once(|_, _| Err(anyhow!("publish dir to ipns failure"))) + .once(); + }) + .with_ipns_key_name("test-key-name") + .with_logger(logger); + + let directory_cid = uploader.batch_upload_to_dir(&[]).await.unwrap(); + + assert_eq!("directory-cid", directory_cid); + assert!(log_inspector.contains_log("publish dir to ipns failure")); + } } } From 49b2f8030dcb7a58fe0961aa2b6a09b4db5d2c2f Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:52:08 +0200 Subject: [PATCH 04/19] feat(aggregator): add configurable `ipns_key_name` for IPNS publishing And wire it to the `IpfsUploader` --- mithril-aggregator/src/configuration.rs | 29 +++++++++++++++++-- .../builder/protocol/artifacts.rs | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/mithril-aggregator/src/configuration.rs b/mithril-aggregator/src/configuration.rs index 09fd56d5814..18d3d8088da 100644 --- a/mithril-aggregator/src/configuration.rs +++ b/mithril-aggregator/src/configuration.rs @@ -501,9 +501,11 @@ pub struct ServeCommandConfiguration { /// /// `mfs_folder_name` (optional) allows overriding the name of the folder in the IPFS MFS (Mutable File System) /// where the snapshots will be stored. Defaults to "mithril" if not specified. + /// `ipns_key_name` (optional) if set, publishes the snapshot MFS folder to IPNS under this key name. The + /// key must already exist in the Kubo node keystore. #[example = "\ `{ \"url\": \"http://localhost:5001/\" }`\ - or `{ \"url\": \"http://localhost:5001/\", \"mfs_folder_name\": \"custom-folder\" }`\ + or `{ \"url\": \"http://localhost:5001/\", \"mfs_folder_name\": \"custom-folder\", \"ipns_key_name\": \"ipns-key\" }`\ "] #[serde( default, @@ -718,6 +720,12 @@ pub struct IpfsRpcServerConfig { /// Defaults to "mithril" if not specified. #[serde(default = "default_mfs_folder_name")] pub mfs_folder_name: String, + + /// Optional name of the IPNS key used to publish the snapshot MFS folder. + /// + /// The key must already exist in the IPFS node keystore. If it is missing or the publication + /// fails, a warning is logged and the snapshot upload still succeeds. + pub ipns_key_name: Option, } impl IpfsRpcServerConfig { @@ -1519,13 +1527,14 @@ mod test { #[test] fn deserializing_ipfs_rpc_server_parameters() { - let deserialized_without_mfs_dir: IpfsRpcServerConfig = + let deserialized_with_defaults: IpfsRpcServerConfig = serde_json::from_str(r#"{ "url": "http://localhost:5001/" }"#).unwrap(); assert_eq!( - deserialized_without_mfs_dir, + deserialized_with_defaults, IpfsRpcServerConfig { url: "http://localhost:5001/".to_string(), mfs_folder_name: default_mfs_folder_name(), + ipns_key_name: None, } ); @@ -1538,6 +1547,20 @@ mod test { IpfsRpcServerConfig { url: "http://localhost:5001/".to_string(), mfs_folder_name: "altered".to_string(), + ipns_key_name: None, + } + ); + + let deserialized_with_ipns_key_name: IpfsRpcServerConfig = serde_json::from_str( + r#"{ "url": "http://localhost:5001/", "ipns_key_name": "my-key" }"#, + ) + .unwrap(); + assert_eq!( + deserialized_with_ipns_key_name, + IpfsRpcServerConfig { + url: "http://localhost:5001/".to_string(), + mfs_folder_name: default_mfs_folder_name(), + ipns_key_name: Some("my-key".to_string()), } ); } diff --git a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs index 6179a9f3016..2acd47dcaa7 100644 --- a/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs +++ b/mithril-aggregator/src/dependency_injection/builder/protocol/artifacts.rs @@ -282,7 +282,7 @@ impl DependenciesBuilder { Ok(IpfsUploader::new( Arc::new(rpc_api_client), IpfsMfsDirPath::from(ipfs_rpc_config.mfs_folder_name), - None, + ipfs_rpc_config.ipns_key_name, FileUploadRetryPolicy::default(), &self.root_logger(), )) From a4340a528644f35ce0f04967a0ed40a1c88c3e7e Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:45:51 +0200 Subject: [PATCH 05/19] chore(ipfs-devnet): remove cdn download support With the last change in IPFS mainternership (see: https://blog.ipfs.tech/2026-08-beyond-sponsored-gateways/), `dist.ipfs.tech` was closed, as such no CDN downloads are available anymore. --- .../commands/mkfiles/kubo-download.sh | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-download.sh b/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-download.sh index 1c016798edf..6f9ba3bb369 100755 --- a/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-download.sh +++ b/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-download.sh @@ -11,7 +11,6 @@ readonly SCRIPT_DIRECTORY source "${SCRIPT_DIRECTORY}/../lib/common.sh" readonly BIN_NAME="kubo" -readonly IPFS_DISTRIBUTIONS_CDN="https://dist.ipfs.tech" readonly GITHUB_RELEASES="https://github.com/ipfs/kubo/releases" readonly CONNECT_TIMEOUT_SECONDS=10 @@ -33,12 +32,6 @@ fetch() { curl --fail --silent --show-error --location --connect-timeout "$CONNECT_TIMEOUT_SECONDS" "$@" } -find_last_released_version_from_cdn() { - # The file have the following structure: one version per line, from earliest to latest, named "vXX.YY.ZZ[-rcN]" (e.g "v0.31.0-rc2" or "v0.33.2") - fetch "${IPFS_DISTRIBUTIONS_CDN}/${BIN_NAME}/versions" | - awk '/^v[0-9]+[.][0-9]+[.][0-9]+$/ { latest = $0 } END { if (latest != "") print latest; else exit 1 }' -} - find_last_released_version_from_github() { # The latest release url redirects to the url of its tag (e.g. "https://github.com/ipfs/kubo/releases/tag/v0.43.1") fetch --head --output /dev/null --write-out '%{url_effective}' "${GITHUB_RELEASES}/latest" | @@ -47,9 +40,8 @@ find_last_released_version_from_github() { find_last_released_version() { local latest_version - latest_version=$(find_last_released_version_from_cdn) || - latest_version=$(find_last_released_version_from_github) || - error_exit "Could not find a released version for '${BIN_NAME}' from '${IPFS_DISTRIBUTIONS_CDN}' or '${GITHUB_RELEASES}'." + latest_version=$(find_last_released_version_from_github) || + error_exit "Could not find a released version for '${BIN_NAME}' from '${GITHUB_RELEASES}'." echo "$latest_version" } @@ -124,14 +116,11 @@ download_bin_archive() { archive_name=$(format_archive_name "$version" "$os" "$arch") local -r archive_path="${download_dir}/${archive_name}" - # example url: https://dist.ipfs.tech/kubo/v0.42.0/kubo_v0.42.0_linux-arm64.tar.gz - local -r cdn_url="${IPFS_DISTRIBUTIONS_CDN}/${BIN_NAME}/${version}/${archive_name}" # example url: https://github.com/ipfs/kubo/releases/download/v0.42.0/kubo_v0.42.0_linux-arm64.tar.gz local -r github_url="${GITHUB_RELEASES}/download/${version}/${archive_name}" - download_bin_archive_from "$cdn_url" "$archive_path" || - download_bin_archive_from "$github_url" "$archive_path" || - error_exit "Failed to download '${BIN_NAME}' archive from '${cdn_url}' or '${github_url}'." + download_bin_archive_from "$github_url" "$archive_path" || + error_exit "Failed to download '${BIN_NAME}' archive from '${github_url}'." echo "$archive_path" } From 58417dab44f65721be578c92b1d04ac5b17a9227 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:32:02 +0200 Subject: [PATCH 06/19] refactor(ipfs-devnet): switch to GitHub-based CAR archive downloads for Web UI - Replace `ipfs.io` with GitHub release API for fetching Web UI CAR archives, since the `ipfs.io` platform was closed. - Add CID validation post-import to ensure the correct file is loaded, before it was passively handled by the download itself. --- mithril-test-lab/ipfs-devnet/README.md | 5 +- .../commands/ipfs-devnet-load-webui.sh | 84 +++++++++++++++---- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/mithril-test-lab/ipfs-devnet/README.md b/mithril-test-lab/ipfs-devnet/README.md index 82ea4199259..0a66b099586 100644 --- a/mithril-test-lab/ipfs-devnet/README.md +++ b/mithril-test-lab/ipfs-devnet/README.md @@ -374,8 +374,11 @@ SWARM_DIR=./swarm ./ipfs-devnet.sh query -- --node 2 swarm peers Download the Kubo Web UI as a CAR archive and import it into the first swarm node, making it available inside the private IPFS network. +The CAR archive is downloaded from the [ipfs-webui GitHub release](https://github.com/ipfs/ipfs-webui/releases) whose +CID matches the Web UI expected by the running Kubo node. The command fails if no release matches this CID. + > [!IMPORTANT] -> The swarm must be running before using this command. The download may take some time. +> The swarm must be running before using this command, and `jq` must be installed. ```shell ./ipfs-devnet.sh load-webui [OPTIONS] diff --git a/mithril-test-lab/ipfs-devnet/commands/ipfs-devnet-load-webui.sh b/mithril-test-lab/ipfs-devnet/commands/ipfs-devnet-load-webui.sh index 4b0c88c6ef2..b9b4d7839f0 100644 --- a/mithril-test-lab/ipfs-devnet/commands/ipfs-devnet-load-webui.sh +++ b/mithril-test-lab/ipfs-devnet/commands/ipfs-devnet-load-webui.sh @@ -10,10 +10,16 @@ readonly SCRIPT_DIRECTORY # shellcheck source=./lib/common.sh source "${SCRIPT_DIRECTORY}/lib/common.sh" +readonly GITHUB_API_RELEASES="https://api.github.com/repos/ipfs/ipfs-webui/releases" +readonly CONNECT_TIMEOUT_SECONDS=10 + display_help() { echo "Download and load the Kubo Web UI on the private IPFS network" echo - echo "IMPORTANT: The IPFS devnet must be running, and the download can be quite slow." + echo "The Web UI CAR archive is downloaded from the ipfs-webui GitHub release matching the Web UI CID" + echo "expected by the running Kubo node." + echo + echo "IMPORTANT: The IPFS devnet must be running." echo echo "Usage: $0 [OPTIONS]" echo @@ -34,13 +40,13 @@ find_web_ui_cid() { local location cid # leverage the 302 Found redirection which contains the CID in its location - # example: `Location: /ipfs/bafybeiciqeyipumpmhxzlxnbqdbbv6u5uij4hy4wax64dmj7kvrhusiq6y` + # example: `http://127.0.0.1:5001/ipfs/bafybeiciqeyipumpmhxzlxnbqdbbv6u5uij4hy4wax64dmj7kvrhusiq6y` location=$( - curl --fail --silent --show-error --head "http://127.0.0.1:${node_port}/webui/" | - awk 'BEGIN { IGNORECASE = 1 } /^Location:/ { sub(/\r$/, ""); print $2; exit }' + curl --fail --silent --show-error --head --output /dev/null --write-out '%{redirect_url}' \ + "http://127.0.0.1:${node_port}/webui/" ) || error_exit "Failed to query Kubo Web UI redirect." - cid=${location#/ipfs/} + cid=${location##*/ipfs/} if [[ -z "$cid" || "$cid" == "$location" ]]; then error_exit "Failed to extract Web UI CID from redirect location: '$location'" @@ -49,20 +55,53 @@ find_web_ui_cid() { printf '%s\n' "$cid" } -download_web_ui_car() { +fetch() { + curl --fail --silent --show-error --location --connect-timeout "$CONNECT_TIMEOUT_SECONDS" "$@" +} + +find_web_ui_car_url_from_github() { + local -r cid="$1" + local releases car_url + + releases=$(fetch -H "Accept: application/vnd.github+json" "${GITHUB_API_RELEASES}?per_page=100") || + error_exit "Failed to list ipfs-webui releases from '${GITHUB_API_RELEASES}'." + + # Each ipfs-webui release body states the CID of its build (e.g. "CID `bafybei...`") + car_url=$( + jq --raw-output --arg cid "$cid" ' + first( + .[] + | select((.body // "") | contains($cid)) + | .assets[] + | select(.name | endswith(".car")) + | .browser_download_url + ) // empty' <<< "$releases" + ) || error_exit "Failed to parse ipfs-webui releases from '${GITHUB_API_RELEASES}'." + + if [[ -z "$car_url" ]]; then + error_exit "No ipfs-webui GitHub release found for CID '${cid}' (searched ${GITHUB_API_RELEASES})." + fi + + printf '%s\n' "$car_url" +} + +download_web_ui_car_from_github() { local -r cid="$1" local -r download_dir="${2%/}" - local -r target_url="https://ipfs.io/ipfs/${cid}?format=car&dag-scope=all" local -r archive_path="${download_dir}/webui-${cid}.car" if [[ -f "$archive_path" ]]; then echo ">> Web UI CAR file already exists: ${archive_path}" >&2 else - echo ">> Downloading Kubo Web UI with CID ${cid} from 'ipfs.io'..." >&2 - curl --fail --silent --show-error --location -H "Accept: application/vnd.ipld.car" \ - --output "$archive_path" "$target_url" || - error_exit "Failed to download Kubo Web UI CAR file from '${target_url}'." + local target_url + target_url=$(find_web_ui_car_url_from_github "$cid") + + echo ">> Downloading Kubo Web UI with CID ${cid} from '${target_url}'..." >&2 + if ! fetch --output "$archive_path" "$target_url"; then + rm -f "$archive_path" + error_exit "Failed to download Kubo Web UI CAR file from '${target_url}'." + fi fi echo "$archive_path" @@ -72,8 +111,19 @@ load_web_ui_car() { local -r car_file_path="$1" local -r ipfs_bin_path="$2" local -r node_dir="${3%/}" - - IPFS_PATH="$node_dir" "$ipfs_bin_path" dag import "$car_file_path" + local -r expected_cid="$4" + local import_output + + import_output=$(IPFS_PATH="$node_dir" "$ipfs_bin_path" dag import --pin-roots --enc=json "$car_file_path") || + error_exit "Failed to import Web UI CAR file '${car_file_path}'." + echo "$import_output" + + # Example of expected output: `{"Root":{"Cid":{"/":"bafybeiciqeyipumpmhxzlxnbqdbbv6u5uij4hy4wax64dmj7kvrhusiq6y"},"PinErrorMsg":""}}` + if ! jq --null-input --exit-status --arg cid "$expected_cid" \ + '[inputs | .Root? | select(.Cid["/"] == $cid and .PinErrorMsg == "")] | any' <<< "$import_output" >/dev/null; then + rm -f "$car_file_path" + error_exit "Imported CAR file root does not match the expected Web UI CID '${expected_cid}', CAR file discarded." + fi } # --------------------------------------------------------------------------- @@ -107,7 +157,7 @@ if [[ "$#" -gt 0 ]]; then error_exit "Unexpected argument: $1" fi -check_requirements "curl" "awk" +check_requirements "curl" "jq" readonly DOWNLOAD_DIR=${DOWNLOAD_DIR:-"/tmp/kubo/"} readonly SWARM_DIR @@ -118,7 +168,7 @@ require_directory "$SWARM_DIR" "-s, --swarm-dir" # Use the first node of the network, it will be propagated from it to other nodes afterwards declare -r NODE_PORT=5001 -declare -r NODE_DIR="${SWARM_DIR}/kubo-node-1" +declare -r NODE_DIR="${SWARM_DIR%/}/kubo-node-1" declare -r IPFS_BIN="${SWARM_DIR%/}/bin/ipfs" require_directory "$NODE_DIR" "Kubo node directory" @@ -136,9 +186,9 @@ CID=$(find_web_ui_cid "$NODE_PORT") readonly CID echo ">> CID found: '$CID'" -CAR_FILE=$(download_web_ui_car "$CID" "$DOWNLOAD_DIR") +CAR_FILE=$(download_web_ui_car_from_github "$CID" "$DOWNLOAD_DIR") readonly CAR_FILE echo ">> Loading Web UI ..." -load_web_ui_car "$CAR_FILE" "$IPFS_BIN" "$NODE_DIR" +load_web_ui_car "$CAR_FILE" "$IPFS_BIN" "$NODE_DIR" "$CID" echo ">> Web UI loaded in the first swarm node ('$NODE_DIR')" From f04fd6665fa5b948c5c75c4fc8882401ae6ec7df Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:39:02 +0200 Subject: [PATCH 07/19] feat(ipfs-devnet): add port usage checks to prevent swarm conflicts A swarm conflict leads to strange bugs and slow networks, this guard should hopefully prevent most of them. --- mithril-test-lab/ipfs-devnet/README.md | 4 +++ .../commands/mkfiles/kubo-configure-swarm.sh | 28 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/mithril-test-lab/ipfs-devnet/README.md b/mithril-test-lab/ipfs-devnet/README.md index 0a66b099586..88f26a0be5b 100644 --- a/mithril-test-lab/ipfs-devnet/README.md +++ b/mithril-test-lab/ipfs-devnet/README.md @@ -152,6 +152,10 @@ For node `N`, the default local ports are: | Gateway | `5100 + N` | `5101` | | Swarm | `5200 + N` | `5201` | +> [!IMPORTANT] +> `init` fails if any of these ports is already in use (e.g. by a swarm still running from this or another +> directory). Stop that swarm first, see [Troubleshooting](#some-kubo-nodes-are-still-running-after-stop). + #### Re-initialize an existing swarm If the target swarm directory already contains node configurations, use `--overwrite`: diff --git a/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-configure-swarm.sh b/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-configure-swarm.sh index 060cfe9bd65..0b17b137998 100755 --- a/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-configure-swarm.sh +++ b/mithril-test-lab/ipfs-devnet/commands/mkfiles/kubo-configure-swarm.sh @@ -10,6 +10,10 @@ readonly SCRIPT_DIRECTORY # shellcheck source=../lib/common.sh source "${SCRIPT_DIRECTORY}/../lib/common.sh" +readonly API_PORT_BASE=5000 +readonly GATEWAY_PORT_BASE=5100 +readonly SWARM_PORT_BASE=5200 + display_help() { echo "Configure a private swarm of Kubo IPFS node" echo @@ -30,6 +34,15 @@ generate_swarm_key() { od -An -N32 -tx1 /dev/urandom | tr -d ' \n' } +# Fail if something is already listening on the given local port, whatever process owns it +require_free_port() { + local -r port="$1" + + if (echo >"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then + error_exit "Port ${port} is already in use: another Kubo swarm may be running, please free the port then try again." + fi +} + # Init a node, removing existing content if overwrite is set, returning the node Peer Id init_node() { local -r node_id="$1" @@ -66,9 +79,9 @@ configure_node() { node_dir="$swarm_dir/kubo-node-$node_id" local api_port gateway_port swarm_port - api_port=$((5000 + node_id)) - gateway_port=$((5100 + node_id)) - swarm_port=$((5200 + node_id)) + api_port=$((API_PORT_BASE + node_id)) + gateway_port=$((GATEWAY_PORT_BASE + node_id)) + swarm_port=$((SWARM_PORT_BASE + node_id)) #---------- Write swarm key { @@ -117,7 +130,7 @@ configure_node_peers() { fi peer_id="${peer_ids[$((peer_node_id - 1))]}" - swarm_port=$((5200 + peer_node_id)) + swarm_port=$((SWARM_PORT_BASE + peer_node_id)) peers_json="${peers_json}${separator}{\"ID\":\"${peer_id}\",\"Addrs\":[\"/ip4/127.0.0.1/tcp/${swarm_port}\"]}" separator="," @@ -167,6 +180,13 @@ fi echo ">> Configuring ${NUMBER_OF_NODES} Kubo nodes in '${SWARM_DIR}'" +# Check ports before touching any node so a running swarm is never orphaned +for ((node_id = 1; node_id <= NUMBER_OF_NODES; node_id++)); do + require_free_port $((API_PORT_BASE + node_id)) + require_free_port $((GATEWAY_PORT_BASE + node_id)) + require_free_port $((SWARM_PORT_BASE + node_id)) +done + SWARM_KEY="$(generate_swarm_key)" readonly SWARM_KEY echo ">> Swarm key generated (not displayed)." From dcae47f37231d7650b9830be476868f4eafc6979 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:01:35 +0200 Subject: [PATCH 08/19] feat(ipfs-devnet): add readiness check with retries for node startup Else requests that requires online node will fail if run very-early. --- .../commands/mkfiles/mkscripts-lifecycle.sh | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/mithril-test-lab/ipfs-devnet/commands/mkfiles/mkscripts-lifecycle.sh b/mithril-test-lab/ipfs-devnet/commands/mkfiles/mkscripts-lifecycle.sh index 6e104c99658..97953f11205 100755 --- a/mithril-test-lab/ipfs-devnet/commands/mkfiles/mkscripts-lifecycle.sh +++ b/mithril-test-lab/ipfs-devnet/commands/mkfiles/mkscripts-lifecycle.sh @@ -57,6 +57,30 @@ error_exit() { exit 1 } +readonly READY_TIMEOUT_SECONDS=30 + +# A node is ready once its daemon has written the 'api' file and answers RPC calls. +# Without the 'api' file check, 'ipfs id' would run offline against the repository and succeed. +wait_for_node_ready() { + local -r node_path="$1" + local -r pid="$2" + local -r log_file="${node_path%/}/ipfs.log" + local i + + for ((i = 0; i < READY_TIMEOUT_SECONDS * 2; i++)); do + if [[ -f "${node_path%/}/api" ]] && IPFS_PATH="$node_path" "$IPFS_BIN" id >/dev/null 2>&1; then + echo ">> Kubo node ready: '$node_path'" + return 0 + fi + if ! kill -0 "$pid" 2>/dev/null; then + error_exit "Kubo node exited during startup: '$node_path' (see '$log_file')" + fi + sleep 0.5 + done + + error_exit "Kubo node not ready after ${READY_TIMEOUT_SECONDS}s: '$node_path' (see '$log_file')" +} + EOF printf 'readonly IPFS_BIN=%s\n' "$(shell_quote "$ipfs_bin")" @@ -114,6 +138,11 @@ for node_path in "${NODES_PATHS[@]}"; do printf '%s\n' "$pid" >"$pid_file" echo ">> Started Kubo node: '$node_path' pid=$pid" done + +# Wait in a separate loop so that all nodes boot in parallel +for node_path in "${NODES_PATHS[@]}"; do + wait_for_node_ready "$node_path" "$(cat "${node_path%/}/ipfs.pid")" +done EOF } > "$script_path" From 77bd6dc228c2e6143a7129d68dc63c39d8c123b7 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:10:57 +0200 Subject: [PATCH 09/19] feat(e2e): create and configure ipfs key name - Auto create the key with a const name - Check if the key name already exist and continue if yes (useful for 'attached' networks) - Automatically passed to the aggregator config Note: no parameter to disable IPNS publishing if ipfs is enabled nor to change the key name, we may add those later but It would bring much right now. --- .../mithril-end-to-end/src/devnet/ipfs.rs | 62 +++++++++++++++++-- .../src/mithril/aggregator.rs | 6 +- .../src/mithril/infrastructure.rs | 5 +- .../mithril-end-to-end/src/mithril/mod.rs | 2 + 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs index 70b47f33e53..f1741b675fd 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs @@ -4,7 +4,7 @@ use std::process::Stdio; use anyhow::{Context, anyhow}; use reqwest::Url; -use slog_scope::info; +use slog_scope::{info, warn}; use tokio::process::Command; use mithril_common::StdResult; @@ -47,10 +47,15 @@ pub struct IpfsDevnetBootstrapArgs { } impl IpfsDevnet { - fn build_command>(&self, sub_command: C) -> StdResult { + fn build_command(&self, sub_command: C, additional_args: A) -> StdResult + where + C: AsRef, + A: IntoIterator, + { let mut command = Command::new(self.devnet_script_path.clone()); command .arg(sub_command) + .args(additional_args) .env("SWARM_DIR", self.swarm_dir.as_os_str()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -176,7 +181,7 @@ impl IpfsDevnet { pub async fn start(&self) -> StdResult<()> { // Note: running the start command on an already running devnet does nothing, so running it // against a "Detached" devnet poses no risk (if stopped, it will start, if running, it will do nothing). - let mut run_command = self.build_command("start")?; + let mut run_command = self.build_command("start", [])?; info!("Starting the IPFS devnet"; "script" => &self.devnet_script_path.display(), "cmd" => "start"); @@ -201,7 +206,7 @@ impl IpfsDevnet { return Ok(()); } - let mut run_command = self.build_command("stop")?; + let mut run_command = self.build_command("stop", [])?; info!("Stopping the IPFS devnet"; "script" => &self.devnet_script_path.display(), "cmd" => "stop"); @@ -217,4 +222,53 @@ impl IpfsDevnet { None => Err(anyhow!("Stop IPFS devnet terminated by signal")), } } + + async fn list_ipns_keys(&self) -> StdResult> { + let output = self + .build_command("query", ["--", "key", "list"])? + .output() + .await + .with_context(|| "Failed to list IPNS keys")?; + if !output.status.success() { + return Err(anyhow!(RetryableDevnetError(format!( + "List IPNS keys failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )))); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect()) + } + + pub async fn create_ipns_keypair(&self, key_name: &str) -> StdResult<()> { + if self.list_ipns_keys().await?.iter().any(|key| key == key_name) { + warn!("Create IPNS keypair - key with name '{key_name}' already exists, reusing it"); + return Ok(()); + } + + let mut run_command = + self.build_command("query", ["--", "key", "gen", "--type=ed25519", key_name])?; + + info!("Creating IPNS keypair"; "script" => &self.devnet_script_path.display(), "cmd" => "query -- key gen"); + + let status = run_command + .spawn() + .with_context(|| "Failed to create IPNS keypair")? + .wait_forwarding_output_to_slog_scope(&format!( + "{IPFS_DEVNET_SCRIPT_NAME} query -- key gen" + )) + .await + .with_context(|| "Error while creating IPNS keypair")?; + match status.code() { + Some(0) => Ok(()), + Some(code) => Err(anyhow!(RetryableDevnetError(format!( + "Create IPNS keypair exited with status code: {code}" + )))), + None => Err(anyhow!("Create IPNS keypair terminated by signal")), + } + } } diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs index b6367959e70..5e7ee6b2db2 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs @@ -19,7 +19,7 @@ use crate::utils::{EnvVars, MithrilCommand, NodeVersion}; use crate::{ ANCILLARY_MANIFEST_SECRET_KEY, AggregateSignatureType, DEVNET_DMQ_MAGIC_ID, DEVNET_MAGIC_ID, DmqNodeFlavor, ERA_MARKERS_SECRET_KEY, ERA_MARKERS_VERIFICATION_KEY, FullNode, GenesisKeys, - KuboNode, PROTOCOL_CONFIGURATION_MARKERS_SECRET_KEY, + IPNS_KEY_NAME_FOR_SNAPSHOT, KuboNode, PROTOCOL_CONFIGURATION_MARKERS_SECRET_KEY, PROTOCOL_CONFIGURATION_MARKERS_VERIFICATION_KEY, RetryableDevnetError, }; @@ -223,8 +223,8 @@ impl Aggregator { env.insert( "IPFS_RPC_SERVER_CONFIG", format!( - r#"{{"url": "{}", "mfs_folder_name": "mithril-snapshots"}}"#, - node.rpc_url + r#"{{"url": "{}", "mfs_folder_name": "mithril-snapshots", "ipns_key_name": "{}"}}"#, + node.rpc_url, IPNS_KEY_NAME_FOR_SNAPSHOT ), ); } diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs index 231c88d63b7..47344a072f6 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs @@ -18,8 +18,8 @@ use crate::mithril::relay_signer::RelaySignerConfiguration; use crate::toolkit::ScenarioToolkit; use crate::{ AggregateSignatureType, Aggregator, AggregatorConfig, Client, DEVNET_MAGIC_ID, Devnet, - DmqNodeFlavor, FullNode, GenesisKeys, IpfsDevnet, KuboNode, PoolNode, RelayAggregator, - RelayPassive, RelaySigner, Signer, + DmqNodeFlavor, FullNode, GenesisKeys, IPNS_KEY_NAME_FOR_SNAPSHOT, IpfsDevnet, KuboNode, + PoolNode, RelayAggregator, RelayPassive, RelaySigner, Signer, }; use super::signer::SignerConfig; @@ -165,6 +165,7 @@ impl MithrilInfrastructure { } if let Some(ipfs_devnet) = &config.ipfs_devnet { ipfs_devnet.start().await?; + ipfs_devnet.create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT).await?; } let devnet_topology = config.devnet.topology(); diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/mod.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/mod.rs index bae555991a1..d61b03527b5 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/mod.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/mod.rs @@ -31,3 +31,5 @@ pub const ANCILLARY_MANIFEST_VERIFICATION_KEY: &str = "5b33322c3235332c3138362c3 pub const ANCILLARY_MANIFEST_SECRET_KEY: &str = "5b3131382c3138342c3232342c3137332c3136302c3234312c36312c3134342c36342c39332c3130362c3232392c38332c3133342c3138392c34302c3138392c3231302c32352c3138342c3136302c3134312c3233372c32362c3136382c35342c3233392c3230342c3133392c3131392c31332c3139395d"; pub const PROTOCOL_CONFIGURATION_MARKERS_VERIFICATION_KEY: &str = "5b33322c3235332c3138362c3230312c3137372c31312c3131372c3133352c3138372c3136372c3138312c3138382c32322c35392c3230362c3130352c3233312c3135302c3231352c33302c37382c3231322c37362c31362c3235322c3138302c37322c3133342c3133372c3234372c3136312c36385d"; pub const PROTOCOL_CONFIGURATION_MARKERS_SECRET_KEY: &str = "5b3131382c3138342c3232342c3137332c3136302c3234312c36312c3134342c36342c39332c3130362c3232392c38332c3133342c3138392c34302c3138392c3231302c32352c3138342c3136302c3134312c3233372c32362c3136382c35342c3233392c3230342c3133392c3131392c31332c3139395d"; + +pub const IPNS_KEY_NAME_FOR_SNAPSHOT: &str = "mithril-snapshot"; From c438079e2fcbd9d2dd517c02d500b96b71fec9d4 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:35:52 +0200 Subject: [PATCH 10/19] refactor(e2e): allow `KuboNode` to send query commands This will allow to easily ask a specific node to resolve the ipns name (required for the upcoming ipns check). details: - Simplified IPFS devnet command generation using the new `IpfsCommandBuilder`. - Added `IpfsCommand` struct for consistent command execution with improved context handling and support for node index. - Updated `start`, `stop`, and `query` methods to use the new builder and executor. - Streamlined keypair management and node topology initialization logic. --- .../mithril-end-to-end/src/devnet/ipfs.rs | 188 +++++++++++------- .../src/mithril/infrastructure.rs | 8 +- 2 files changed, 127 insertions(+), 69 deletions(-) diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs index f1741b675fd..f6669c658ba 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs @@ -1,6 +1,6 @@ -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::process::Stdio; +use std::ops::{Deref, DerefMut}; +use std::path::{Display, Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; use anyhow::{Context, anyhow}; use reqwest::Url; @@ -23,18 +23,20 @@ pub enum IpfsDevnetMode { Detached, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct IpfsDevnet { - devnet_script_path: PathBuf, swarm_dir: PathBuf, topology: Vec, mode: IpfsDevnetMode, + command_builder: IpfsCommandBuilder, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct KuboNode { pub rpc_url: Url, pub working_dir: PathBuf, + swarm_index: u8, + command_builder: IpfsCommandBuilder, } #[derive(Debug, Clone)] @@ -46,12 +48,32 @@ pub struct IpfsDevnetBootstrapArgs { pub mode: IpfsDevnetMode, } -impl IpfsDevnet { - fn build_command(&self, sub_command: C, additional_args: A) -> StdResult - where - C: AsRef, - A: IntoIterator, - { +#[derive(Debug, Clone)] +struct IpfsCommandBuilder { + devnet_script_path: PathBuf, + swarm_dir: PathBuf, +} + +impl IpfsCommandBuilder { + fn new(devnet_script_path: &Path, swarm_dir: &Path) -> Self { + Self { + devnet_script_path: devnet_script_path.to_owned(), + swarm_dir: swarm_dir.to_owned(), + } + } + + fn display_script_path(&self) -> Display<'_> { + self.devnet_script_path.display() + } + + fn build(&self, sub_command: &str, additional_args: &[&str]) -> StdResult { + let mut context = format!("{IPFS_DEVNET_SCRIPT_NAME} {sub_command}",); + + if !additional_args.is_empty() { + context.push(' '); + context.push_str(&additional_args.join(" ")); + } + let mut command = Command::new(self.devnet_script_path.clone()); command .arg(sub_command) @@ -60,14 +82,68 @@ impl IpfsDevnet { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - Ok(command) + + Ok(IpfsCommand { command, context }) + } + + fn build_query_command( + &self, + node_swarm_index: u8, + query_args: &[&str], + ) -> StdResult { + let node_swarm_index_str = format!("{node_swarm_index}"); + let additional_args = + [vec!["--", "-n", &node_swarm_index_str, "--"], query_args.to_vec()].concat(); + + self.build("query", &additional_args) + } +} + +struct IpfsCommand { + command: Command, + context: String, +} + +impl IpfsCommand { + async fn execute(mut self) -> StdResult { + let exit_status = self + .command + .spawn() + .with_context(|| format!("Failed to spawn '{}'", self.context))? + .wait_forwarding_output_to_slog_scope(&self.context) + .await + .with_context(|| format!("Execution of '{}' failed", self.context))?; + + Ok(exit_status) } +} - fn build_topology(number_of_nodes: u8, swarm_dir: &Path) -> Vec { +impl Deref for IpfsCommand { + type Target = Command; + + fn deref(&self) -> &Self::Target { + &self.command + } +} + +impl DerefMut for IpfsCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.command + } +} + +impl IpfsDevnet { + fn build_topology( + number_of_nodes: u8, + swarm_dir: &Path, + command_builder: &IpfsCommandBuilder, + ) -> Vec { (1..=number_of_nodes) .map(|n| KuboNode { rpc_url: Url::parse(&format!("http://127.0.0.1:{}", 5000_u16 + n as u16)).unwrap(), working_dir: swarm_dir.join(format!("kubo-node-{n}")), + swarm_index: n, + command_builder: command_builder.clone(), }) .collect() } @@ -107,14 +183,16 @@ impl IpfsDevnet { } } + let command_builder = IpfsCommandBuilder::new(&devnet_script_path, &swarm_dir); Ok(IpfsDevnet { - devnet_script_path, swarm_dir: bootstrap_args.swarm_target_dir.to_owned(), topology: Self::build_topology( bootstrap_args.number_of_nodes, &bootstrap_args.swarm_target_dir, + &command_builder, ), mode: bootstrap_args.mode, + command_builder, }) } @@ -125,43 +203,32 @@ impl IpfsDevnet { IPFS_DEVNET_SCRIPT_NAME, &bootstrap_args.devnet_scripts_dir, )?; + let command_builder = + IpfsCommandBuilder::new(&devnet_script_path, &bootstrap_args.swarm_target_dir); - let mut bootstrap_command = Command::new(&devnet_script_path); - bootstrap_command - .arg("init") - .arg("--overwrite") - .arg("--number") - .arg(bootstrap_args.number_of_nodes.to_string()) - .arg("--swarm-dir") - .arg(bootstrap_args.swarm_target_dir.as_os_str()); + let mut bootstrap_command = command_builder.build( + "init", + &["--overwrite", "--number", &bootstrap_args.number_of_nodes.to_string()], + )?; if let Some(kubo_version) = &bootstrap_args.kubo_version { bootstrap_command.env("KUBO_VERSION", kubo_version.to_string()); } - bootstrap_command - .current_dir(&bootstrap_args.devnet_scripts_dir) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); + bootstrap_command.current_dir(&bootstrap_args.devnet_scripts_dir); - info!("Bootstrapping the IPFS Devnet"; "script" => &devnet_script_path.display(), "cmd" => "init"); + info!("Bootstrapping the IPFS Devnet"; "script" => command_builder.display_script_path(), "cmd" => "init"); - let exit_status = bootstrap_command - .spawn() - .with_context(|| format!("{IPFS_DEVNET_SCRIPT_NAME} failed to start"))? - .wait_forwarding_output_to_slog_scope(IPFS_DEVNET_SCRIPT_NAME) - .await - .with_context(|| format!("{IPFS_DEVNET_SCRIPT_NAME} failed to run"))?; - match exit_status.code() { + match bootstrap_command.execute().await?.code() { Some(0) => Ok(IpfsDevnet { - devnet_script_path, swarm_dir: bootstrap_args.swarm_target_dir.to_owned(), topology: Self::build_topology( bootstrap_args.number_of_nodes, &bootstrap_args.swarm_target_dir, + &command_builder, ), mode: bootstrap_args.mode, + command_builder, }), Some(code) => Err(anyhow!(RetryableDevnetError(format!( "IPFS Bootstrap devnet exited with status code: {code}" @@ -181,17 +248,11 @@ impl IpfsDevnet { pub async fn start(&self) -> StdResult<()> { // Note: running the start command on an already running devnet does nothing, so running it // against a "Detached" devnet poses no risk (if stopped, it will start, if running, it will do nothing). - let mut run_command = self.build_command("start", [])?; + let run_command = self.command_builder.build("start", &[])?; - info!("Starting the IPFS devnet"; "script" => &self.devnet_script_path.display(), "cmd" => "start"); + info!("Starting the IPFS devnet"; "script" => &self.command_builder.display_script_path(), "cmd" => "start"); - let status = run_command - .spawn() - .with_context(|| "Failed to start the IPFS devnet")? - .wait_forwarding_output_to_slog_scope(&format!("{IPFS_DEVNET_SCRIPT_NAME} start")) - .await - .with_context(|| "Error while starting the IPFS devnet")?; - match status.code() { + match run_command.execute().await?.code() { Some(0) => Ok(()), Some(code) => Err(anyhow!(RetryableDevnetError(format!( "Run IPFS devnet exited with status code: {code}" @@ -206,26 +267,23 @@ impl IpfsDevnet { return Ok(()); } - let mut run_command = self.build_command("stop", [])?; + let run_command = self.command_builder.build("stop", &[])?; - info!("Stopping the IPFS devnet"; "script" => &self.devnet_script_path.display(), "cmd" => "stop"); + info!("Stopping the IPFS devnet"; "script" => &self.command_builder.display_script_path(), "cmd" => "stop"); - let exit_status = run_command - .spawn() - .with_context(|| "Failed to stop the IPFS devnet")? - .wait_forwarding_output_to_slog_scope(&format!("{IPFS_DEVNET_SCRIPT_NAME} stop")) - .await - .with_context(|| "Error while stopping the IPFS devnet")?; - match exit_status.code() { + match run_command.execute().await?.code() { Some(0) => Ok(()), Some(code) => Err(anyhow!("Stop IPFS devnet exited with status code: {code}")), None => Err(anyhow!("Stop IPFS devnet terminated by signal")), } } +} +impl KuboNode { async fn list_ipns_keys(&self) -> StdResult> { let output = self - .build_command("query", ["--", "key", "list"])? + .command_builder + .build_query_command(self.swarm_index, &["key", "list"])? .output() .await .with_context(|| "Failed to list IPNS keys")?; @@ -250,20 +308,14 @@ impl IpfsDevnet { return Ok(()); } - let mut run_command = - self.build_command("query", ["--", "key", "gen", "--type=ed25519", key_name])?; + let query_command = self.command_builder.build_query_command( + self.swarm_index, + &["key", "gen", "--type=ed25519", key_name], + )?; - info!("Creating IPNS keypair"; "script" => &self.devnet_script_path.display(), "cmd" => "query -- key gen"); + info!("Creating IPNS keypair"; "script" => &self.command_builder.display_script_path(), "cmd" => "query -- key gen"); - let status = run_command - .spawn() - .with_context(|| "Failed to create IPNS keypair")? - .wait_forwarding_output_to_slog_scope(&format!( - "{IPFS_DEVNET_SCRIPT_NAME} query -- key gen" - )) - .await - .with_context(|| "Error while creating IPNS keypair")?; - match status.code() { + match query_command.execute().await?.code() { Some(0) => Ok(()), Some(code) => Err(anyhow!(RetryableDevnetError(format!( "Create IPNS keypair exited with status code: {code}" diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs index 47344a072f6..e58e90aee14 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use serde::{Deserialize, Serialize}; use slog_scope::info; use std::collections::BTreeSet; @@ -165,7 +166,12 @@ impl MithrilInfrastructure { } if let Some(ipfs_devnet) = &config.ipfs_devnet { ipfs_devnet.start().await?; - ipfs_devnet.create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT).await?; + ipfs_devnet + .topology() + .first() + .with_context(|| "IPFS devnet should have at least one node")? + .create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT) + .await?; } let devnet_topology = config.devnet.topology(); From 28d0de88a27b065e4abb753d1679ee351bfa6f3c Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:19:42 +0200 Subject: [PATCH 11/19] feat(e2e): new + enhanced queries + better command with output handling - Added `execute_with_output` method to `IpfsCommand` for execute a command for which we read its outputs. - Enhanced `list_ipns_keys` to return key ID and name as tuples. - New queries: - `get_ipns_key_id`: to fetch IPNS key IDs by name. - `ipns_name_resolve`: to resolve IPNS names to IPFS paths / CID. - `ipfs_resolve`: to resolve IPFS paths to their corresponding CIDs. --- .../mithril-end-to-end/src/devnet/ipfs.rs | 121 ++++++++++++++---- .../mithril-end-to-end/src/devnet/mod.rs | 2 +- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs index f6669c658ba..b7c2bc45e99 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs @@ -48,6 +48,12 @@ pub struct IpfsDevnetBootstrapArgs { pub mode: IpfsDevnetMode, } +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct IpnsKey { + pub id: String, + pub name: String, +} + #[derive(Debug, Clone)] struct IpfsCommandBuilder { devnet_script_path: PathBuf, @@ -66,8 +72,8 @@ impl IpfsCommandBuilder { self.devnet_script_path.display() } - fn build(&self, sub_command: &str, additional_args: &[&str]) -> StdResult { - let mut context = format!("{IPFS_DEVNET_SCRIPT_NAME} {sub_command}",); + fn build(&self, sub_command: &str, additional_args: &[&str]) -> IpfsCommand { + let mut context = format!("{IPFS_DEVNET_SCRIPT_NAME} {sub_command}"); if !additional_args.is_empty() { context.push(' '); @@ -83,15 +89,13 @@ impl IpfsCommandBuilder { .stderr(Stdio::piped()) .kill_on_drop(true); - Ok(IpfsCommand { command, context }) + IpfsCommand { command, context } } - fn build_query_command( - &self, - node_swarm_index: u8, - query_args: &[&str], - ) -> StdResult { - let node_swarm_index_str = format!("{node_swarm_index}"); + fn build_query_command(&self, node_swarm_index: u8, query_args: &[&str]) -> IpfsCommand { + let node_swarm_index_str = node_swarm_index.to_string(); + // First `--` forwards arguments to the generated `$SWARM_DIR/query.sh` command + // Second `--` ends `query.sh` options, ensuring that all remaining arguments are forwarded to the executed `ipfs` command. let additional_args = [vec!["--", "-n", &node_swarm_index_str, "--"], query_args.to_vec()].concat(); @@ -105,6 +109,9 @@ struct IpfsCommand { } impl IpfsCommand { + /// Execute the IPFS command and return the exit status. + /// + /// Stdout and stderr are forwarded to the slog scope logger. async fn execute(mut self) -> StdResult { let exit_status = self .command @@ -116,6 +123,28 @@ impl IpfsCommand { Ok(exit_status) } + + /// Execute the IPFS command and return the output. + /// + /// If the command fails, an error is returned. + async fn execute_with_output(mut self) -> StdResult { + let output = self + .command + .output() + .await + .with_context(|| format!("Failed to spawn '{}'", self.context))?; + + if output.status.success() { + Ok(output) + } else { + Err(anyhow!( + "Execution of '{}' failed with status {}: {}", + self.context, + output.status, + String::from_utf8_lossy(&output.stderr) + )) + } + } } impl Deref for IpfsCommand { @@ -209,7 +238,7 @@ impl IpfsDevnet { let mut bootstrap_command = command_builder.build( "init", &["--overwrite", "--number", &bootstrap_args.number_of_nodes.to_string()], - )?; + ); if let Some(kubo_version) = &bootstrap_args.kubo_version { bootstrap_command.env("KUBO_VERSION", kubo_version.to_string()); @@ -248,7 +277,7 @@ impl IpfsDevnet { pub async fn start(&self) -> StdResult<()> { // Note: running the start command on an already running devnet does nothing, so running it // against a "Detached" devnet poses no risk (if stopped, it will start, if running, it will do nothing). - let run_command = self.command_builder.build("start", &[])?; + let run_command = self.command_builder.build("start", &[]); info!("Starting the IPFS devnet"; "script" => &self.command_builder.display_script_path(), "cmd" => "start"); @@ -267,7 +296,7 @@ impl IpfsDevnet { return Ok(()); } - let run_command = self.command_builder.build("stop", &[])?; + let run_command = self.command_builder.build("stop", &[]); info!("Stopping the IPFS devnet"; "script" => &self.command_builder.display_script_path(), "cmd" => "stop"); @@ -280,30 +309,37 @@ impl IpfsDevnet { } impl KuboNode { - async fn list_ipns_keys(&self) -> StdResult> { + /// List IPNS keys of the Kubo node keystore + pub async fn list_ipns_keys(&self) -> StdResult> { + // Output lines example: + // k51qzi5uqu5dhmp0n2e4olicqyqure0kk8qq87y8s2o8qyyeah2hugrmo9boq7 self + // k51qzi5uqu5dldmb73ienkcee5fm46ldactwcuu0wzqizcwgas9en94spxr16j mithril-snapshot let output = self .command_builder - .build_query_command(self.swarm_index, &["key", "list"])? - .output() - .await - .with_context(|| "Failed to list IPNS keys")?; - if !output.status.success() { - return Err(anyhow!(RetryableDevnetError(format!( - "List IPNS keys failed with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )))); - } + .build_query_command(self.swarm_index, &["key", "list", "-l"]) + .execute_with_output() + .await?; Ok(String::from_utf8_lossy(&output.stdout) .lines() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()) + .filter_map(|line| line.trim().split_once(char::is_whitespace)) + .map(|(id, name)| IpnsKey { + id: id.to_string(), + name: name.to_string(), + }) .collect()) } + /// Get the id of the IPNS key with the given name + /// + /// Note: The key name is local to the IPFS node and can't be used to resolve IPNS names, only the key id can. + pub async fn get_ipns_key_id(&self, key_name: &str) -> StdResult> { + let keys = self.list_ipns_keys().await?; + Ok(keys.into_iter().find(|key| key.name == key_name).map(|key| key.id)) + } + pub async fn create_ipns_keypair(&self, key_name: &str) -> StdResult<()> { - if self.list_ipns_keys().await?.iter().any(|key| key == key_name) { + if self.get_ipns_key_id(key_name).await?.is_some() { warn!("Create IPNS keypair - key with name '{key_name}' already exists, reusing it"); return Ok(()); } @@ -311,7 +347,7 @@ impl KuboNode { let query_command = self.command_builder.build_query_command( self.swarm_index, &["key", "gen", "--type=ed25519", key_name], - )?; + ); info!("Creating IPNS keypair"; "script" => &self.command_builder.display_script_path(), "cmd" => "query -- key gen"); @@ -323,4 +359,33 @@ impl KuboNode { None => Err(anyhow!("Create IPNS keypair terminated by signal")), } } + + /// Resolve an **IPNS** name to an IPFS path. + pub async fn ipns_name_resolve(&self, ipns_name: &str) -> StdResult { + // Example output: + // /ipfs/QmfYpbWwaAmrTfapNZHYDkutDxckvqA5hzjrbfYDwymaKw + let output = self + .command_builder + .build_query_command( + self.swarm_index, + &["name", "resolve", "--nocache", ipns_name], + ) + .execute_with_output() + .await?; + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Resolve an **IPFS** path (useful to retrieve the CID of a file if you only know the path and its folder CID). + pub async fn ipfs_resolve(&self, ipfs_path: &str) -> StdResult { + // Example output: + // /ipfs/bafkreibeffv3u3yqulwvgxqjcfsigocas35t5rjykzk5nmyev2x2fzwe3y + let output = self + .command_builder + .build_query_command(self.swarm_index, &["resolve", ipfs_path]) + .execute_with_output() + .await?; + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } } diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/mod.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/mod.rs index 9a34dd90621..1a01bbbf268 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/mod.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/mod.rs @@ -2,7 +2,7 @@ mod cardano; mod ipfs; pub use cardano::{Devnet, DevnetBootstrapArgs, DevnetTopology, FullNode, PoolNode}; -pub use ipfs::{IpfsDevnet, IpfsDevnetBootstrapArgs, IpfsDevnetMode, KuboNode}; +pub use ipfs::{IpfsDevnet, IpfsDevnetBootstrapArgs, IpfsDevnetMode, IpnsKey, KuboNode}; #[derive(thiserror::Error, Debug, PartialEq, Eq)] #[error("Retryable devnet error: `{0}`")] From c865328310d5b4b54f307d131b8301fb8352ca65 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:28:03 +0200 Subject: [PATCH 12/19] feat(e2e): check IPNS publication in cardano-db scenario - `Aggregator` hold its associated `KuboNode` if IPFS is enabled. - Implemented `aggregator_publish_ipfs_uploaded_artifact_to_ipns` for verifying IPNS publication of artifacts. --- .../src/mithril/aggregator.rs | 7 ++ .../src/mithril/infrastructure.rs | 16 ++-- .../mithril-end-to-end/src/scenario/full.rs | 2 +- .../src/scenario/minimal.rs | 2 +- .../src/toolkit/check/cardano_database.rs | 73 ++++++++++++++++++- 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs index 5e7ee6b2db2..e723fcf348c 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/aggregator.rs @@ -64,6 +64,7 @@ pub struct Aggregator { process: RwLock>, chain_observer: Arc, full_node: FullNode, + ipfs_kubo_node: Option, aggregate_signature_type: AggregateSignatureType, startup_protocol_parameters: ProtocolParameters, } @@ -258,6 +259,7 @@ impl Aggregator { process: RwLock::new(None), chain_observer, full_node: aggregator_config.full_node.clone(), + ipfs_kubo_node: aggregator_config.ipfs_kubo_node.cloned(), aggregate_signature_type: aggregator_config.aggregate_signature_type, startup_protocol_parameters: aggregator_config.startup_protocol_parameters.clone(), }) @@ -324,6 +326,7 @@ impl Aggregator { process: RwLock::new(None), chain_observer: other.chain_observer.clone(), full_node: other.full_node.clone(), + ipfs_kubo_node: other.ipfs_kubo_node.clone(), aggregate_signature_type: other.aggregate_signature_type, startup_protocol_parameters: other.startup_protocol_parameters.clone(), } @@ -365,6 +368,10 @@ impl Aggregator { &self.full_node } + pub fn ipfs_kubo_node(&self) -> Option<&KuboNode> { + self.ipfs_kubo_node.as_ref() + } + /// Get the version of the mithril-aggregator binary. pub fn version(&self) -> &NodeVersion { &self.version diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs index e58e90aee14..2fbdd261801 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs @@ -166,12 +166,6 @@ impl MithrilInfrastructure { } if let Some(ipfs_devnet) = &config.ipfs_devnet { ipfs_devnet.start().await?; - ipfs_devnet - .topology() - .first() - .with_context(|| "IPFS devnet should have at least one node")? - .create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT) - .await?; } let devnet_topology = config.devnet.topology(); @@ -383,6 +377,16 @@ impl MithrilInfrastructure { certificate_chain_aggregator_endpoint: Option, ) -> StdResult { let aggregator_name = Aggregator::name_suffix(index); + + if let Some(kubo_node) = ipfs_kubo_node { + kubo_node + .create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT) + .await + .with_context(|| { + format!("Failed to create IPNS keypair for aggregator {aggregator_name}") + })?; + } + let aggregator_artifacts_dir = config .artifacts_dir .join(format!("mithril-aggregator-{aggregator_name}")); diff --git a/mithril-test-lab/mithril-end-to-end/src/scenario/full.rs b/mithril-test-lab/mithril-end-to-end/src/scenario/full.rs index 52f6a7d7a4f..2b7bdc2fefe 100644 --- a/mithril-test-lab/mithril-end-to-end/src/scenario/full.rs +++ b/mithril-test-lab/mithril-end-to-end/src/scenario/full.rs @@ -386,7 +386,7 @@ impl FullScenario { .is_certified_and_verified( aggregator, &mut client, - infrastructure.client_ipfs_node().map(|n| n.rpc_url.clone()), + infrastructure.client_ipfs_node(), expected_epoch_min, infrastructure.signers().len(), ) diff --git a/mithril-test-lab/mithril-end-to-end/src/scenario/minimal.rs b/mithril-test-lab/mithril-end-to-end/src/scenario/minimal.rs index 2757cb3e66b..2fb09681b0a 100644 --- a/mithril-test-lab/mithril-end-to-end/src/scenario/minimal.rs +++ b/mithril-test-lab/mithril-end-to-end/src/scenario/minimal.rs @@ -185,7 +185,7 @@ impl MinimalScenario { .is_certified_and_verified( aggregator, &mut client, - infrastructure.client_ipfs_node().map(|n| n.rpc_url.clone()), + infrastructure.client_ipfs_node(), expected_epoch_min, infrastructure.signers().len(), ) diff --git a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs index eac8340f64f..67eb23c2e72 100644 --- a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs +++ b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs @@ -4,12 +4,14 @@ use slog_scope::{info, warn}; use mithril_common::{ StdResult, - entities::{Epoch, EpochSpecifier}, + entities::{Epoch, EpochSpecifier, ImmutableFileNumber}, messages::{CardanoDatabaseDigestListMessage, CardanoDatabaseSnapshotListItemMessage}, }; use crate::{ - Aggregator, CardanoDbV2Command, Client, ClientCommand, poll_until, + Aggregator, CardanoDbV2Command, Client, ClientCommand, KuboNode, + mithril::IPNS_KEY_NAME_FOR_SNAPSHOT, + poll_until, toolkit::{CheckCertificateToolkit, ScenarioToolkitContext}, utils::AttemptResult, }; @@ -30,7 +32,7 @@ impl CheckCardanoDatabaseToolkit { &self, aggregator: &Aggregator, client: &mut Client, - ipfs_rpc_url: Option, + client_ipfs_node: Option<&KuboNode>, expected_epoch_min: Epoch, total_signers_expected: usize, ) -> StdResult<()> { @@ -45,7 +47,28 @@ impl CheckCardanoDatabaseToolkit { ) .await?; self.node_producing_cardano_database_digests_map(aggregator).await?; - self.verify_with_client(client, ipfs_rpc_url, &artifact.hash).await?; + self.verify_with_client( + client, + client_ipfs_node.map(|n| n.rpc_url.clone()), + &artifact.hash, + ) + .await?; + + if let Some(client_kubo_node) = client_ipfs_node { + let aggregator_kubo_node = aggregator.ipfs_kubo_node().with_context(|| { + format!( + "IPFS devnet is enabled but {} has no Kubo node", + aggregator.name() + ) + })?; + self.check_immutable_published_to_ipns( + aggregator, + aggregator_kubo_node, + client_kubo_node, + artifact.beacon.immutable_file_number, + ) + .await?; + } Ok(()) } @@ -169,4 +192,46 @@ impl CheckCardanoDatabaseToolkit { Ok(()) } + + pub async fn check_immutable_published_to_ipns( + &self, + aggregator: &Aggregator, + aggregator_kubo_node: &KuboNode, + client_kubo_node: &KuboNode, + immutable_file_number: ImmutableFileNumber, + ) -> StdResult<()> { + info!( + "Checking IPNS publication of immutable file #{immutable_file_number}"; + "aggregator" => &aggregator.name(), + ); + + let ipns_key_id = aggregator_kubo_node + .get_ipns_key_id(IPNS_KEY_NAME_FOR_SNAPSHOT) + .await? + .with_context(|| { + format!( + "Failed to get IPNS key ID for key '{IPNS_KEY_NAME_FOR_SNAPSHOT}' and {}", + aggregator.name() + ) + })?; + info!("IPNS key ID for key '{IPNS_KEY_NAME_FOR_SNAPSHOT}': '{ipns_key_id}'"; "aggregator" => &aggregator.name()); + + // Note: we could use `ipfs resolve` directly with the ipns key id, but this would make the + // query prone to cache issues since this request does not allow ignoring the cache (contrary + // to `ipfs name resolve` which has a `--nocache` option). + let ipfs_dir_path = client_kubo_node + .ipns_name_resolve(&format!("/ipns/{ipns_key_id}")) + .await?; + info!("'/ipns/{ipns_key_id}' resolves to: '{ipfs_dir_path}'"; "aggregator" => &aggregator.name()); + + let ipfs_path_for_immutable = + format!("{ipfs_dir_path}/{immutable_file_number:0>5}.tar.zst"); + client_kubo_node.ipfs_resolve(&ipfs_path_for_immutable).await?; + info!( + "Success, '/ipns/{ipns_key_id}' contains immutable {immutable_file_number:0>5}"; + "aggregator" => &aggregator.name() + ); + + Ok(()) + } } From 4e90386c85796b11915dcf7e22d16a17dfc55114 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:42:25 +0200 Subject: [PATCH 13/19] refactor(e2e): add polling for the check IPNS directory publish To reduce potential flakiness for that check. --- .../mithril-end-to-end/src/devnet/ipfs.rs | 17 ++++++ .../src/toolkit/check/cardano_database.rs | 57 +++++++++++++------ 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs index b7c2bc45e99..fc9d5f033c6 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs @@ -388,4 +388,21 @@ impl KuboNode { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } + + /// Resolve the given IPNS name and check that the resolved directory contains the given file, + /// returning the resolved directory path. + pub async fn resolve_file_in_ipns_dir( + &self, + ipns_name: &str, + ipfs_file_name: &str, + ) -> StdResult { + // Note: we could use `ipfs resolve` directly with the ipns key id, but this would make the + // query prone to cache issues since this request does not allow ignoring the cache (contrary + // to `ipfs name resolve` which has a `--nocache` option). + let ipfs_dir_path = self.ipns_name_resolve(ipns_name).await?; + self.ipfs_resolve(&format!("{ipfs_dir_path}/{ipfs_file_name}")) + .await?; + + Ok(ipfs_dir_path) + } } diff --git a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs index 67eb23c2e72..140bd12563d 100644 --- a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs +++ b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs @@ -216,22 +216,47 @@ impl CheckCardanoDatabaseToolkit { })?; info!("IPNS key ID for key '{IPNS_KEY_NAME_FOR_SNAPSHOT}': '{ipns_key_id}'"; "aggregator" => &aggregator.name()); - // Note: we could use `ipfs resolve` directly with the ipns key id, but this would make the - // query prone to cache issues since this request does not allow ignoring the cache (contrary - // to `ipfs name resolve` which has a `--nocache` option). - let ipfs_dir_path = client_kubo_node - .ipns_name_resolve(&format!("/ipns/{ipns_key_id}")) - .await?; - info!("'/ipns/{ipns_key_id}' resolves to: '{ipfs_dir_path}'"; "aggregator" => &aggregator.name()); - - let ipfs_path_for_immutable = - format!("{ipfs_dir_path}/{immutable_file_number:0>5}.tar.zst"); - client_kubo_node.ipfs_resolve(&ipfs_path_for_immutable).await?; - info!( - "Success, '/ipns/{ipns_key_id}' contains immutable {immutable_file_number:0>5}"; - "aggregator" => &aggregator.name() - ); + let ipns_name = format!("/ipns/{ipns_key_id}"); + let ipfs_file_name_for_immutable = format!("{immutable_file_number:0>5}.tar.zst"); + let mut last_error = None; - Ok(()) + match poll_until!( + self.context.existing_artifact_fetch_timeout(), + self.context.poll_backoff(), + { + match client_kubo_node.resolve_file_in_ipns_dir( + &ipns_name, + &ipfs_file_name_for_immutable, + ) + .await + { + Ok(ipfs_dir_path) => Ok(Some(ipfs_dir_path)), + Err(error) => { + warn!("IPNS publication not yet available, retrying"; "error" => ?error, "aggregator" => &aggregator.name()); + last_error = Some(error); + Ok(None) + } + } + } + ) { + AttemptResult::Ok(ipfs_dir_path) => { + info!( + "Success, '{ipns_name}' resolves to '{ipfs_dir_path}' that contains immutable {immutable_file_number:0>5}"; + "aggregator" => &aggregator.name() + ); + Ok(()) + } + AttemptResult::Err(error) => Err(error), + AttemptResult::Timeout(..) => { + let error = anyhow!( + "Timeout exhausted while checking that '{ipns_name}' contains immutable {immutable_file_number:0>5}" + ); + Err(match last_error { + Some(last_error) => last_error.context(error), + None => error, + }) + } + } + .with_context(|| format!("Checking IPNS publication of aggregator `{}`", aggregator.name())) } } From 7baba6c7889f8e5b7df5f9cc15bc44213ba7b346 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:11:49 +0200 Subject: [PATCH 14/19] feat(aggregator): add IPNS key verification before directory publishing to IPNS Not strictly needed has the code was already only throwing a warning if `/v0/name/publish` failed, but allow us to log an error that we control instead of relying on the one thrown by Kubo. --- .../src/file_uploaders/ipfs_uploader.rs | 121 ++++++++++++++- .../kubo_rpc_client/query/ipfs_key_ls.rs | 139 ++++++++++++++++++ .../src/tools/kubo_rpc_client/query/mod.rs | 2 + 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_key_ls.rs diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 041882610d2..6bb5ce11319 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -13,7 +13,8 @@ use crate::FileUploader; use crate::file_uploaders::FileUploadRetryPolicy; use crate::file_uploaders::interface::retry; use crate::tools::kubo_rpc_client::query::{ - IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, IpfsNamePublishQuery, + IpfsAddQuery, IpfsFilesLsQuery, IpfsFilesMkdirQuery, IpfsFilesStatQuery, IpfsKeyLsQuery, + IpfsNamePublishQuery, }; use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; @@ -154,8 +155,36 @@ impl IpfsUploader { Ok(FileUri(cid)) } + /// Check that the given IPNS key is available in the IPFS node keystore, logging a warning + /// if it is not or if the check fails. + /// + /// Allows logging a cleaner message than just relying on the error returned by the IPFS node. + async fn check_ipns_key_availability(&self, ipns_key_name: &str) -> bool { + match self.rpc_client.list_ipns_key_names().await { + Ok(key_names) if key_names.iter().any(|name| name == ipns_key_name) => true, + Ok(_) => { + warn!( + self.logger, "IPNS key not found in the IPFS node, skipping publication of the directory to IPNS. The key must be imported in the IPFS node keystore"; + "key_name" => ipns_key_name + ); + false + } + Err(err) => { + warn!( + self.logger, "Failed to list the IPFS node keys, skipping publication of the directory to IPNS"; + "err" => ?err, "key_name" => ipns_key_name + ); + false + } + } + } + async fn publish_directory_to_ipns(&self, dir_cid: &IpfsCid) { if let Some(key) = &self.ipns_key_name { + if !self.check_ipns_key_availability(key).await { + return; + } + trace!(self.logger, "Publishing dir to IPNS"; "dir_cid" => dir_cid, "key_name" => key); match self.rpc_client.publish_dir_to_ipns(dir_cid, key).await { @@ -209,6 +238,9 @@ pub trait IpfsBackendUploader: Sync + Send { /// Publish a directory to IPNS using a given key async fn publish_dir_to_ipns(&self, dir_cid: &IpfsCid, key_name: &str) -> StdResult<()>; + + /// List the names of the keys available in the IPFS node keystore + async fn list_ipns_key_names(&self) -> StdResult>; } #[async_trait::async_trait] @@ -255,6 +287,11 @@ impl IpfsBackendUploader for KuboRpcClient { self.send(IpfsNamePublishQuery::publish_to_ipns(object_path, key_name)) .await } + + async fn list_ipns_key_names(&self) -> StdResult> { + let keys = self.send(IpfsKeyLsQuery::new()).await?; + Ok(keys.into_iter().map(|key| key.name).collect()) + } } #[cfg(test)] @@ -418,6 +455,9 @@ mod tests { .with(eq(IpfsMfsDirPath::from(MFS_DIR))) .return_once(|_| Ok("directory-cid".to_string())) .once(); + mock.expect_list_ipns_key_names() + .return_once(|| Ok(vec!["self".to_string(), "test-key-name".to_string()])) + .once(); mock.expect_publish_dir_to_ipns() .with(eq("directory-cid".to_string()), eq("test-key-name")) .return_once(|_, _| Ok(())) @@ -632,6 +672,9 @@ mod tests { mock.expect_get_dir_cid() .return_once(|_| Ok("directory-cid".to_string())) .once(); + mock.expect_list_ipns_key_names() + .return_once(|| Ok(vec!["test-key-name".to_string()])) + .once(); mock.expect_publish_dir_to_ipns() .return_once(|_, _| Err(anyhow!("publish dir to ipns failure"))) .once(); @@ -644,5 +687,81 @@ mod tests { assert_eq!("directory-cid", directory_cid); assert!(log_inspector.contains_log("publish dir to ipns failure")); } + + #[tokio::test] + async fn skip_publishing_to_ipns_without_failing_if_ipns_key_is_not_available() { + let (logger, log_inspector) = TestLogger::memory(); + let uploader = IpfsUploader::new_for_test(MFS_DIR, move |mock| { + mock.expect_create_dir().return_once(|_| Ok(())).once(); + mock.expect_list_directory_files() + .return_once(|_| Ok(HashSet::new())) + .once(); + mock.expect_upload_file().never(); + mock.expect_get_dir_cid() + .return_once(|_| Ok("directory-cid".to_string())) + .once(); + mock.expect_list_ipns_key_names() + .return_once(|| Ok(vec!["self".to_string()])) + .once(); + mock.expect_publish_dir_to_ipns().never(); + }) + .with_ipns_key_name("test-key-name") + .with_logger(logger); + + let directory_cid = uploader.batch_upload_to_dir(&[]).await.unwrap(); + + assert_eq!("directory-cid", directory_cid); + assert!(log_inspector.contains_log("IPNS key not found in the IPFS node")); + } + } + + mod check_ipns_key_availability { + use anyhow::anyhow; + + use super::*; + + #[tokio::test] + async fn key_is_available_if_listed_in_the_ipfs_node_keys() { + let (logger, log_inspector) = TestLogger::memory(); + let uploader = IpfsUploader::new_for_test("/test", |mock| { + mock.expect_list_ipns_key_names() + .return_once(|| Ok(vec!["self".to_string(), "test-key-name".to_string()])) + .once(); + }) + .with_logger(logger); + + assert!(uploader.check_ipns_key_availability("test-key-name").await); + assert!(!log_inspector.contains_log("test-key-name")); + } + + #[tokio::test] + async fn key_is_not_available_and_warn_if_not_listed_in_the_ipfs_node_keys() { + let (logger, log_inspector) = TestLogger::memory(); + let uploader = IpfsUploader::new_for_test("/test", |mock| { + mock.expect_list_ipns_key_names() + .return_once(|| Ok(vec!["self".to_string(), "another-key".to_string()])) + .once(); + }) + .with_logger(logger); + + assert!(!uploader.check_ipns_key_availability("test-key-name").await); + assert!(log_inspector.contains_log("IPNS key not found in the IPFS node")); + assert!(log_inspector.contains_log("test-key-name")); + } + + #[tokio::test] + async fn key_is_not_available_and_warn_if_listing_the_ipfs_node_keys_fails() { + let (logger, log_inspector) = TestLogger::memory(); + let uploader = IpfsUploader::new_for_test("/test", |mock| { + mock.expect_list_ipns_key_names() + .return_once(|| Err(anyhow!("list ipns keys failure"))) + .once(); + }) + .with_logger(logger); + + assert!(!uploader.check_ipns_key_availability("test-key-name").await); + assert!(log_inspector.contains_log("Failed to list the IPFS node keys")); + assert!(log_inspector.contains_log("list ipns keys failure")); + } } } diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_key_ls.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_key_ls.rs new file mode 100644 index 00000000000..d7474e211b3 --- /dev/null +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_key_ls.rs @@ -0,0 +1,139 @@ +use anyhow::Context; +use reqwest::Response; +use serde::Deserialize; + +use mithril_common::StdResult; + +use crate::tools::kubo_rpc_client::KuboRpcQuery; + +/// Query to list the keys stored in the IPFS node keystore (used to sign IPNS records). +/// +/// see: https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-key-ls +#[derive(Debug, Default)] +pub struct IpfsKeyLsQuery; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct IpfsKeyLsResponse { + keys: Option>, +} + +/// A key stored in the IPFS node keystore +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct IpfsKey { + /// Name of the key, local to the IPFS node + pub name: String, + /// Id of the key, usable to resolve the IPNS records signed with this key + pub id: String, +} + +impl IpfsKeyLsQuery { + /// Create a query that will list the keys of the IPFS node keystore. + pub fn new() -> Self { + Self + } +} + +#[async_trait::async_trait] +impl KuboRpcQuery for IpfsKeyLsQuery { + type Response = Vec; + + fn route(&self) -> String { + "api/v0/key/ls".to_string() + } + + async fn handle_success(&self, response: Response) -> StdResult { + let response: IpfsKeyLsResponse = response + .json() + .await + .with_context(|| "Failed to deserialize IPFS key ls response")?; + + Ok(response.keys.unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + + use crate::tools::kubo_rpc_client::test_tools::setup_server_and_client; + + use super::*; + + #[tokio::test] + async fn return_keys_list_if_request_succeeds() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/key/ls"); + then.status(200).json_body(serde_json::json!({ + "Keys": [ + { "Name": "self", "Id": "k51qzi5uqu5djd98ozajs6h4ljp0p53lqf4fn4p3904t20yqjbc2nksxotpz66" }, + { "Name": "mithril-snapshot", "Id": "k51qzi5uqu5di91xjrh1a4hs0gd7l22tdoyh1odjjfu40vllpi1p9i2jert6sp" } + ] + })); + }); + + let response = client.send(IpfsKeyLsQuery::new()).await.unwrap(); + + assert_eq!( + vec![ + IpfsKey { + name: "self".to_string(), + id: "k51qzi5uqu5djd98ozajs6h4ljp0p53lqf4fn4p3904t20yqjbc2nksxotpz66" + .to_string(), + }, + IpfsKey { + name: "mithril-snapshot".to_string(), + id: "k51qzi5uqu5di91xjrh1a4hs0gd7l22tdoyh1odjjfu40vllpi1p9i2jert6sp" + .to_string(), + }, + ], + response + ); + } + + #[tokio::test] + async fn return_empty_list_when_keys_are_null() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/key/ls"); + then.status(200).json_body(serde_json::json!({ "Keys": null })); + }); + + let response = client.send(IpfsKeyLsQuery::new()).await.unwrap(); + + assert_eq!(Vec::::new(), response); + } + + #[tokio::test] + async fn return_error_if_response_body_is_malformed() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/key/ls"); + then.status(200).json_body(serde_json::json!({ "Keys": "invalid" })); + }); + + let err = client.send(IpfsKeyLsQuery::new()).await.unwrap_err(); + + assert!( + err.to_string().contains("Failed to deserialize IPFS key ls response"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn return_error_if_request_fails() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST).path("/api/v0/key/ls"); + then.status(500).json_body( + serde_json::json!({"Message":"keystore unavailable","Code":0,"Type":"error"}), + ); + }); + + let err = client.send(IpfsKeyLsQuery::new()).await.unwrap_err(); + + assert!(err.to_string().contains("keystore unavailable")); + } +} diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs index 522d0b3a71c..915ddbf2a12 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/mod.rs @@ -2,10 +2,12 @@ mod ipfs_add; mod ipfs_files_ls; mod ipfs_files_mkdir; mod ipfs_files_stat; +mod ipfs_key_ls; mod ipfs_name_publish; pub use ipfs_add::*; pub use ipfs_files_ls::*; pub use ipfs_files_mkdir::*; pub use ipfs_files_stat::*; +pub use ipfs_key_ls::*; pub use ipfs_name_publish::*; From 9636bedd91589999877753be25b6fad23c47492a Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:05:40 +0200 Subject: [PATCH 15/19] refactor(aggregator): log IPFS uploader batch outcomes at debug level Log the outcomes of batch operations at `debug` level so they are visible without enabling `trace` logs: - batch upload to the IPFS MFS directory finished, with the directory CID - directory published to IPNS Per-file logs stay at `trace` level: a batch can contain thousands of files (~10k immutable files on mainnet), which would flood the logs. --- mithril-aggregator/src/file_uploaders/ipfs_uploader.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index 6bb5ce11319..ea59e4581db 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context; -use slog::{Logger, trace, warn}; +use slog::{Logger, debug, trace, warn}; use mithril_common::StdResult; use mithril_common::entities::FileUri; @@ -63,6 +63,7 @@ impl IpfsUploader { .await .with_context(|| "listing files in IPFS MFS directory")?; + let mut uploaded_files = 0; for file_path in files { let filename = file_path @@ -86,9 +87,14 @@ impl IpfsUploader { format!(" Uploaded file path: {}", file_path.display()), ) .await?; + uploaded_files += 1; } let dir_cid = self.get_current_directory_cid().await?; + debug!( + self.logger, "Batch upload to IPFS finished"; + "dir_cid" => %dir_cid, "uploaded_files" => uploaded_files, "already_present_files" => files.len() - uploaded_files + ); self.publish_directory_to_ipns(&dir_cid).await; @@ -189,7 +195,7 @@ impl IpfsUploader { match self.rpc_client.publish_dir_to_ipns(dir_cid, key).await { Ok(()) => { - trace!(self.logger, "Directory published to IPNS"; "dir_cid" => dir_cid, "key_name" => key); + debug!(self.logger, "Directory published to IPNS"; "dir_cid" => dir_cid, "key_name" => key); } Err(err) => { warn!( From 73631c4181114fdb3756d2f74876b66ba69cc338 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:39:16 +0200 Subject: [PATCH 16/19] docs(website): add configuration details for IPFS RPC server in aggregator manual --- docs/website/root/manual/develop/nodes/mithril-aggregator.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/website/root/manual/develop/nodes/mithril-aggregator.md b/docs/website/root/manual/develop/nodes/mithril-aggregator.md index 2f736e9bb32..48998d4629b 100644 --- a/docs/website/root/manual/develop/nodes/mithril-aggregator.md +++ b/docs/website/root/manual/develop/nodes/mithril-aggregator.md @@ -588,6 +588,7 @@ Here is a list of the available parameters for the serve command: | `cardano_prover_max_hashes_allowed_by_request` | `--cardano-prover-max-hashes-allowed-by-request` | - | `CARDANO_PROVER_MAX_HASHES_ALLOWED_BY_REQUEST` | Maximum number of hashes allowed by request to the Cardano prover (applies to both transaction hashes and block hashes) | `100` | `100` | - | | `cardano_transactions_block_streamer_max_roll_forwards_per_poll` | `--cardano-transactions-block-streamer-max-roll-forwards-per-poll` | - | `CARDANO_TRANSACTIONS_BLOCK_STREAMER_MAX_ROLL_FORWARDS_PER_POLL` | Maximum number of roll forwards during a poll of the block streamer when importing transactions | `1000` | `1000` | - | | `preload_security_parameter` | - | - | `PRELOAD_SECURITY_PARAMETER` | Blocks offset, from the tip of the chain, to exclude during the cardano transactions preload
`[default: 2160]`. | `2160` | - | :heavy_check_mark: | +| `ipfs_rpc_server_config` | - | - | `IPFS_RPC_SERVER_CONFIG` | URL of a Kubo IPFS RPC API, setting this will enable IPFS upload for immutable snapshots

`mfs_folder_name` (optional) allows overriding the name of the folder in the IPFS MFS (Mutable File System)
where the snapshots will be stored. Defaults to "mithril" if not specified.
`ipns_key_name` (optional) if set, publishes the snapshot MFS folder to IPNS under this key name. The
key must already exist in the Kubo node keystore. | - | `{ "url": "http://localhost:5001/" }`or `{ "url": "http://localhost:5001/", "mfs_folder_name": "custom-folder", "ipns_key_name": "ipns-key" }` | - | | `enable_metrics_server` | `--enable-metrics-server` | - | `ENABLE_METRICS_SERVER` | Enable metrics HTTP server (Prometheus endpoint on /metrics) | `false` | - | - | | `metrics_server_ip` | `--metrics-server-ip` | - | `METRICS_SERVER_IP` | Metrics HTTP server IP | `0.0.0.0` | - | - | | `metrics_server_port` | `--metrics-server-port` | - | `METRICS_SERVER_PORT` | Metrics HTTP server listening port | `9090` | - | - | From 9925d4cb04337ccf71cf8dffc15c9eb03893f0a6 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:39:38 +0200 Subject: [PATCH 17/19] chore: PR reviews adjustments - e2e: scoped IPNS key creation and testing to the leader aggregator only - e2e: removed unused `swarm_dir` from `IpfsDevnet` struct - rustdoc adjustments --- .../src/tools/kubo_rpc_client/query/ipfs_name_publish.rs | 2 +- mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs | 7 ------- .../mithril-end-to-end/src/mithril/infrastructure.rs | 3 ++- .../src/toolkit/check/cardano_database.rs | 4 +++- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs index 8df779b4b38..796e927ebc6 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs @@ -18,7 +18,7 @@ pub struct IpfsNamePublishQuery { impl IpfsNamePublishQuery { /// Lifetime in hours of the IPNS record in the DHT (using kubo default: 48 hours). const BASE_LIFETIME_HOURS: u64 = 48; - /// Cache TTL in seconds of the IPNS record in the Kubo node (using kubo default: 5 minutes). + /// Cache TTL in seconds of the IPNS record (using kubo default: 5 minutes). const BASE_TTL_SECONDS: u64 = 60 * 5; /// Create a query that will publish the given IPFS object to IPNS. diff --git a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs index fc9d5f033c6..c964e04fe9e 100644 --- a/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs +++ b/mithril-test-lab/mithril-end-to-end/src/devnet/ipfs.rs @@ -25,7 +25,6 @@ pub enum IpfsDevnetMode { #[derive(Debug, Clone)] pub struct IpfsDevnet { - swarm_dir: PathBuf, topology: Vec, mode: IpfsDevnetMode, command_builder: IpfsCommandBuilder, @@ -214,7 +213,6 @@ impl IpfsDevnet { let command_builder = IpfsCommandBuilder::new(&devnet_script_path, &swarm_dir); Ok(IpfsDevnet { - swarm_dir: bootstrap_args.swarm_target_dir.to_owned(), topology: Self::build_topology( bootstrap_args.number_of_nodes, &bootstrap_args.swarm_target_dir, @@ -250,7 +248,6 @@ impl IpfsDevnet { match bootstrap_command.execute().await?.code() { Some(0) => Ok(IpfsDevnet { - swarm_dir: bootstrap_args.swarm_target_dir.to_owned(), topology: Self::build_topology( bootstrap_args.number_of_nodes, &bootstrap_args.swarm_target_dir, @@ -266,10 +263,6 @@ impl IpfsDevnet { } } - pub fn swarm_dir(&self) -> PathBuf { - self.swarm_dir.clone() - } - pub fn topology(&self) -> &[KuboNode] { &self.topology } diff --git a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs index 2fbdd261801..27e0626a20e 100644 --- a/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs +++ b/mithril-test-lab/mithril-end-to-end/src/mithril/infrastructure.rs @@ -377,8 +377,9 @@ impl MithrilInfrastructure { certificate_chain_aggregator_endpoint: Option, ) -> StdResult { let aggregator_name = Aggregator::name_suffix(index); + let is_leader = index == 0; - if let Some(kubo_node) = ipfs_kubo_node { + if is_leader && let Some(kubo_node) = ipfs_kubo_node { kubo_node .create_ipns_keypair(IPNS_KEY_NAME_FOR_SNAPSHOT) .await diff --git a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs index 140bd12563d..ee7a02640b3 100644 --- a/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs +++ b/mithril-test-lab/mithril-end-to-end/src/toolkit/check/cardano_database.rs @@ -54,7 +54,9 @@ impl CheckCardanoDatabaseToolkit { ) .await?; - if let Some(client_kubo_node) = client_ipfs_node { + if aggregator.is_leader() + && let Some(client_kubo_node) = client_ipfs_node + { let aggregator_kubo_node = aggregator.ipfs_kubo_node().with_context(|| { format!( "IPFS devnet is enabled but {} has no Kubo node", From fc398abd3e1365290e6bc7dcc642ed745bc83f03 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:42:09 +0200 Subject: [PATCH 18/19] feat(aggregator): make IPNS record lifetime and TTL configurable Only on the query level, but this allow the IPFS uploader to provide values specific for the mithril snapshot upload usecase. - Add `with_lifetime` and `with_ttl` setters to `IpfsNamePublishQuery`, taking a `std::time::Duration` (sent to kubo in seconds, defaults unchanged: 48h / 5min) - Publish IPNS records from the IPFS uploader with a 5 days lifetime and a 30 minutes TTL --- .../src/file_uploaders/ipfs_uploader.rs | 14 ++++- .../query/ipfs_name_publish.rs | 63 ++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs index ea59e4581db..106a61b9455 100644 --- a/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs +++ b/mithril-aggregator/src/file_uploaders/ipfs_uploader.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; use anyhow::Context; use slog::{Logger, debug, trace, warn}; @@ -21,6 +22,11 @@ use crate::tools::kubo_rpc_client::{IpfsMfsDirPath, KuboRpcClient}; /// IPFS Content Identifier (CID) pub type IpfsCid = String; +/// Lifetime of the IPNS records published by the uploader in the DHT. +const IPNS_RECORD_LIFETIME: Duration = Duration::from_hours(5 * 24); +/// Cache TTL of the IPNS records published by the uploader. +const IPNS_RECORD_TTL: Duration = Duration::from_mins(30); + /// File uploader that stores files to IPFS pub struct IpfsUploader { rpc_client: Arc, @@ -290,8 +296,12 @@ impl IpfsBackendUploader for KuboRpcClient { async fn publish_dir_to_ipns(&self, dir_cid: &IpfsCid, key_name: &str) -> StdResult<()> { let object_path = format!("/ipfs/{dir_cid}"); - self.send(IpfsNamePublishQuery::publish_to_ipns(object_path, key_name)) - .await + self.send( + IpfsNamePublishQuery::publish_to_ipns(object_path, key_name) + .with_lifetime(IPNS_RECORD_LIFETIME) + .with_ttl(IPNS_RECORD_TTL), + ) + .await } async fn list_ipns_key_names(&self) -> StdResult> { diff --git a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs index 796e927ebc6..c00ccb6a6d2 100644 --- a/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs +++ b/mithril-aggregator/src/tools/kubo_rpc_client/query/ipfs_name_publish.rs @@ -13,13 +13,15 @@ use crate::tools::kubo_rpc_client::{KuboRpcQuery, TIMEOUT_FOR_SLOW_QUERY_SECS}; pub struct IpfsNamePublishQuery { ipfs_object_path: String, ipfs_key: String, + lifetime: Duration, + ttl: Duration, } impl IpfsNamePublishQuery { - /// Lifetime in hours of the IPNS record in the DHT (using kubo default: 48 hours). - const BASE_LIFETIME_HOURS: u64 = 48; - /// Cache TTL in seconds of the IPNS record (using kubo default: 5 minutes). - const BASE_TTL_SECONDS: u64 = 60 * 5; + /// Default lifetime of the IPNS record in the DHT (using kubo default: 48 hours). + const DEFAULT_LIFETIME: Duration = Duration::from_hours(48); + /// Default cache TTL of the IPNS record (using kubo default: 5 minutes). + const DEFAULT_TTL: Duration = Duration::from_mins(5); /// Create a query that will publish the given IPFS object to IPNS. pub fn publish_to_ipns, K: Into>( @@ -29,8 +31,31 @@ impl IpfsNamePublishQuery { Self { ipfs_object_path: ipfs_object_path.into(), ipfs_key: ipfs_key.into(), + lifetime: Self::DEFAULT_LIFETIME, + ttl: Self::DEFAULT_TTL, } } + + /// Set the lifetime of the IPNS record in the DHT. + /// + /// Sent to kubo with a seconds precision, sub-second parts are truncated. + pub fn with_lifetime(mut self, lifetime: Duration) -> Self { + self.lifetime = lifetime; + self + } + + /// Set the cache TTL of the IPNS record. + /// + /// Sent to kubo with a seconds precision, sub-second parts are truncated. + pub fn with_ttl(mut self, ttl: Duration) -> Self { + self.ttl = ttl; + self + } + + /// Format a duration to a format accepted by kubo (Go `time.ParseDuration`). + fn format_duration(duration: Duration) -> String { + format!("{}s", duration.as_secs()) + } } #[async_trait::async_trait] @@ -48,8 +73,8 @@ impl KuboRpcQuery for IpfsNamePublishQuery { Ok(request_builder.query(&[ ("arg", &self.ipfs_object_path), ("key", &self.ipfs_key), - ("lifetime", &format!("{}h", Self::BASE_LIFETIME_HOURS)), - ("ttl", &format!("{}s", Self::BASE_TTL_SECONDS)), + ("lifetime", &Self::format_duration(self.lifetime)), + ("ttl", &Self::format_duration(self.ttl)), ])) } @@ -81,7 +106,7 @@ mod tests { "/ipfs/QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR", ) .query_param("key", "my-key-name") - .query_param("lifetime", "48h") + .query_param("lifetime", "172800s") .query_param("ttl", "300s"); then.status(200); }); @@ -95,6 +120,30 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn send_configured_lifetime_and_ttl_in_seconds() { + let (server, client) = setup_server_and_client(); + server.mock(|when, then| { + when.method(POST) + .path("/api/v0/name/publish") + .query_param("lifetime", "120s") + .query_param("ttl", "1s"); + then.status(200); + }); + + client + .send( + IpfsNamePublishQuery::publish_to_ipns( + "/ipfs/QmYi7wrRFKVCcTB56A6Pep2j31Q5mHfmmu21RzHXu25RVR", + "my-key-name", + ) + .with_lifetime(Duration::from_mins(2)) + .with_ttl(Duration::from_millis(1500)), + ) + .await + .unwrap(); + } + #[tokio::test] async fn return_error_if_request_fails_with_other_message() { let (server, client) = setup_server_and_client(); From d95bd12792db541fd3564d6347f8e72db0e802b7 Mon Sep 17 00:00:00 2001 From: DJO <790521+Alenar@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:13:49 +0200 Subject: [PATCH 19/19] chore: upgrade crate versions and `mithril-test-lab/ipfs-devnet/VERSION` version * mithril-aggregator from `0.10.20` to `0.10.21` * mithril-end-to-end from `0.5.26` to `0.5.27` * mithril-test-lab/ipfs-devnet/VERSION from `0.1.2` to `0.1.3` --- Cargo.lock | 4 ++-- mithril-aggregator/Cargo.toml | 2 +- mithril-test-lab/ipfs-devnet/VERSION | 2 +- mithril-test-lab/mithril-end-to-end/Cargo.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e14eec1597..38bfc448f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4351,7 +4351,7 @@ dependencies = [ [[package]] name = "mithril-aggregator" -version = "0.10.20" +version = "0.10.21" dependencies = [ "anyhow", "async-trait", @@ -4764,7 +4764,7 @@ dependencies = [ [[package]] name = "mithril-end-to-end" -version = "0.5.26" +version = "0.5.27" dependencies = [ "anyhow", "async-trait", diff --git a/mithril-aggregator/Cargo.toml b/mithril-aggregator/Cargo.toml index 9e11fe2a65d..f9f2e74e73e 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-aggregator" -version = "0.10.20" +version = "0.10.21" description = "A Mithril Aggregator server" authors = { workspace = true } edition = { workspace = true } diff --git a/mithril-test-lab/ipfs-devnet/VERSION b/mithril-test-lab/ipfs-devnet/VERSION index 22920c6163f..39b89960093 100644 --- a/mithril-test-lab/ipfs-devnet/VERSION +++ b/mithril-test-lab/ipfs-devnet/VERSION @@ -1,2 +1,2 @@ -0.1.2 +0.1.3 diff --git a/mithril-test-lab/mithril-end-to-end/Cargo.toml b/mithril-test-lab/mithril-end-to-end/Cargo.toml index 626f90918e9..e276366fb99 100644 --- a/mithril-test-lab/mithril-end-to-end/Cargo.toml +++ b/mithril-test-lab/mithril-end-to-end/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-end-to-end" -version = "0.5.26" +version = "0.5.27" authors = { workspace = true } edition = { workspace = true } documentation = { workspace = true }