This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git
The following commit(s) were added to refs/heads/main by this push:
new 3651322d7 feat(services/tos): add volcengine TOS support (#7233)
3651322d7 is described below
commit 3651322d7a7f6c62f0bff127705df6e8a00ccb94
Author: Xin Sun <[email protected]>
AuthorDate: Wed Mar 11 00:07:38 2026 +0800
feat(services/tos): add volcengine TOS support (#7233)
Change-Id: I12a490356a0996c4ce4f56614da62ca0bd8849ea
---
.env.example | 5 +
core/Cargo.lock | 10 ++
core/Cargo.toml | 2 +
core/services/tos/Cargo.toml | 36 +++++++
core/services/tos/src/backend.rs | 199 +++++++++++++++++++++++++++++++++++++
core/services/tos/src/config.rs | 210 +++++++++++++++++++++++++++++++++++++++
core/services/tos/src/core.rs | 39 ++++++++
core/services/tos/src/lib.rs | 35 +++++++
core/src/lib.rs | 3 +
9 files changed, 539 insertions(+)
diff --git a/.env.example b/.env.example
index bd6741dc6..df2b81e3c 100644
--- a/.env.example
+++ b/.env.example
@@ -199,3 +199,8 @@ OPENDAL_CLOUDFLARE_KV_ROOT=/path/to/dir
OPENDAL_CLOUDFLARE_KV_API_TOKEN=<api_token>
OPENDAL_CLOUDFLARE_KV_ACCOUNT_ID=<account_id>
OPENDAL_CLOUDFLARE_KV_NAMESPACE_ID=<namespace_id>
+# tos
+OPENDAL_TOS_BUCKET=<bucket>
+OPENDAL_TOS_ENDPOINT=<endpoint>
+OPENDAL_TOS_ACCESS_KEY_ID=<access_key_id>
+OPENDAL_TOS_ACCESS_KEY_SECRET=<access_key_secret>
diff --git a/core/Cargo.lock b/core/Cargo.lock
index 837d3f170..01cfa9ae8 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -5968,6 +5968,7 @@ dependencies = [
"opendal-service-surrealdb",
"opendal-service-swift",
"opendal-service-tikv",
+ "opendal-service-tos",
"opendal-service-upyun",
"opendal-service-vercel-artifacts",
"opendal-service-vercel-blob",
@@ -7088,6 +7089,15 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "opendal-service-tos"
+version = "0.55.0"
+dependencies = [
+ "opendal-core",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "opendal-service-upyun"
version = "0.55.0"
diff --git a/core/Cargo.toml b/core/Cargo.toml
index b64fdbe08..a8f05cb0f 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -182,6 +182,7 @@ services-sqlite = ["dep:opendal-service-sqlite"]
services-surrealdb = ["dep:opendal-service-surrealdb"]
services-swift = ["dep:opendal-service-swift"]
services-tikv = ["dep:opendal-service-tikv"]
+services-tos = ["dep:opendal-service-tos"]
services-upyun = ["dep:opendal-service-upyun"]
services-vercel-artifacts = ["dep:opendal-service-vercel-artifacts"]
services-vercel-blob = ["dep:opendal-service-vercel-blob"]
@@ -289,6 +290,7 @@ opendal-service-sqlite = { path = "services/sqlite",
version = "0.55.0", optiona
opendal-service-surrealdb = { path = "services/surrealdb", version = "0.55.0",
optional = true, default-features = false }
opendal-service-swift = { path = "services/swift", version = "0.55.0",
optional = true, default-features = false }
opendal-service-tikv = { path = "services/tikv", version = "0.55.0", optional
= true, default-features = false }
+opendal-service-tos = { path = "services/tos", version = "0.55.0", optional =
true, default-features = false }
opendal-service-upyun = { path = "services/upyun", version = "0.55.0",
optional = true, default-features = false }
opendal-service-vercel-artifacts = { path = "services/vercel-artifacts",
version = "0.55.0", optional = true, default-features = false }
opendal-service-vercel-blob = { path = "services/vercel-blob", version =
"0.55.0", optional = true, default-features = false }
diff --git a/core/services/tos/Cargo.toml b/core/services/tos/Cargo.toml
new file mode 100644
index 000000000..c11607f65
--- /dev/null
+++ b/core/services/tos/Cargo.toml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+description = "Volcengine TOS service implementation for Apache OpenDAL"
+name = "opendal-service-tos"
+
+authors = { workspace = true }
+edition = { workspace = true }
+homepage = { workspace = true }
+license = { workspace = true }
+repository = { workspace = true }
+rust-version = { workspace = true }
+version = { workspace = true }
+
+[package.metadata.docs.rs]
+all-features = true
+
+[dependencies]
+opendal-core = { path = "../../core", version = "0.55.0", default-features =
false }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
diff --git a/core/services/tos/src/backend.rs b/core/services/tos/src/backend.rs
new file mode 100644
index 000000000..3e3963584
--- /dev/null
+++ b/core/services/tos/src/backend.rs
@@ -0,0 +1,199 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::Debug;
+use std::sync::Arc;
+
+use crate::TosConfig;
+use crate::core::TosCore;
+use opendal_core::raw::*;
+use opendal_core::{Builder, Capability, Result};
+
+const TOS_SCHEME: &str = "tos";
+
+/// Builder for Volcengine TOS service.
+#[derive(Default)]
+pub struct TosBuilder {
+ pub(super) config: TosConfig,
+}
+
+impl Debug for TosBuilder {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("TosBuilder")
+ .field("config", &self.config)
+ .finish_non_exhaustive()
+ }
+}
+
+impl TosBuilder {
+ /// Set root of this backend.
+ ///
+ /// All operations will happen under this root.
+ pub fn root(mut self, root: &str) -> Self {
+ self.config.root = if root.is_empty() {
+ None
+ } else {
+ Some(root.to_string())
+ };
+ self
+ }
+
+ /// Set bucket name of this backend.
+ pub fn bucket(mut self, bucket: &str) -> Self {
+ self.config.bucket = bucket.to_string();
+ self
+ }
+
+ /// Set endpoint of this backend.
+ ///
+ /// Endpoint must be full uri, e.g.
+ /// - TOS: `https://tos-cn-beijing.volces.com`
+ /// - TOS with region: `https://tos-{region}.volces.com`
+ ///
+ /// If user inputs endpoint without scheme like
"tos-cn-beijing.volces.com", we
+ /// will prepend "https://" before it.
+ pub fn endpoint(mut self, endpoint: &str) -> Self {
+ if !endpoint.is_empty() {
+ self.config.endpoint =
Some(endpoint.trim_end_matches('/').to_string());
+ }
+ self
+ }
+
+ /// Set region of this backend.
+ ///
+ /// Region represent the signing region of this endpoint.
+ ///
+ /// If region is not set, we will try to load it from environment.
+ /// If still not set, default to `cn-beijing`.
+ pub fn region(mut self, region: &str) -> Self {
+ if !region.is_empty() {
+ self.config.region = Some(region.to_string());
+ }
+ self
+ }
+
+ /// Set access_key_id of this backend.
+ ///
+ /// - If access_key_id is set, we will take user's input first.
+ /// - If not, we will try to load it from environment.
+ pub fn access_key_id(mut self, v: &str) -> Self {
+ self.config.access_key_id = Some(v.to_string());
+ self
+ }
+
+ /// Set secret_access_key of this backend.
+ ///
+ /// - If secret_access_key is set, we will take user's input first.
+ /// - If not, we will try to load it from environment.
+ pub fn secret_access_key(mut self, v: &str) -> Self {
+ self.config.secret_access_key = Some(v.to_string());
+ self
+ }
+
+ /// Set security_token of this backend.
+ pub fn security_token(mut self, v: &str) -> Self {
+ self.config.security_token = Some(v.to_string());
+ self
+ }
+
+ /// Allow anonymous will allow opendal to send request without signing
+ /// when credential is not loaded.
+ pub fn allow_anonymous(mut self, allow: bool) -> Self {
+ self.config.allow_anonymous = allow;
+ self
+ }
+
+ /// Set bucket versioning status for this backend.
+ ///
+ /// If set to true, OpenDAL will support versioned operations like list
with
+ /// versions, read with version, etc.
+ pub fn enable_versioning(mut self, enabled: bool) -> Self {
+ self.config.enable_versioning = enabled;
+ self
+ }
+}
+
+impl Builder for TosBuilder {
+ type Config = TosConfig;
+
+ fn build(self) -> Result<impl Access> {
+ let mut config = self.config;
+ let region = config
+ .region
+ .clone()
+ .unwrap_or_else(|| "cn-beijing".to_string());
+
+ if config.endpoint.is_none() {
+ config.endpoint = Some(format!("https://tos-{}.volces.com",
region));
+ }
+
+ let endpoint = config.endpoint.clone().unwrap();
+ let bucket = config.bucket.clone();
+ let root = config.root.clone().unwrap_or_else(|| "/".to_string());
+
+ let info = {
+ let am = AccessorInfo::default();
+ am.set_scheme(TOS_SCHEME)
+ .set_root(&root)
+ .set_name(&bucket)
+ .set_native_capability(Capability {
+ read: false,
+
+ write: false,
+
+ delete: false,
+
+ list: false,
+
+ stat: false,
+
+ shared: false,
+
+ ..Default::default()
+ });
+
+ am.into()
+ };
+
+ let core = TosCore {
+ info,
+ bucket,
+ endpoint: endpoint.clone(),
+ root,
+ };
+
+ Ok(TosBackend {
+ core: Arc::new(core),
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct TosBackend {
+ core: Arc<TosCore>,
+}
+
+impl Access for TosBackend {
+ type Reader = ();
+ type Writer = ();
+ type Lister = ();
+ type Deleter = ();
+
+ fn info(&self) -> Arc<AccessorInfo> {
+ self.core.info.clone()
+ }
+}
diff --git a/core/services/tos/src/config.rs b/core/services/tos/src/config.rs
new file mode 100644
index 000000000..bde4dde03
--- /dev/null
+++ b/core/services/tos/src/config.rs
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::Debug;
+
+use opendal_core::Configurator;
+use opendal_core::OperatorUri;
+use opendal_core::Result;
+use serde::Deserialize;
+use serde::Serialize;
+
+use crate::backend::TosBuilder;
+
+/// Config for Volcengine TOS service.
+#[derive(Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[serde(default)]
+#[non_exhaustive]
+pub struct TosConfig {
+ /// root of this backend.
+ ///
+ /// All operations will happen under this root.
+ ///
+ /// default to `/` if not set.
+ pub root: Option<String>,
+ /// bucket name of this backend.
+ ///
+ /// required.
+ pub bucket: String,
+ /// endpoint of this backend.
+ ///
+ /// Endpoint must be full uri, e.g.
+ /// - TOS: `https://tos-cn-beijing.volces.com`
+ /// - TOS with region: `https://tos-{region}.volces.com`
+ ///
+ /// If user inputs endpoint without scheme like
"tos-cn-beijing.volces.com", we
+ /// will prepend "https://" before it.
+ pub endpoint: Option<String>,
+ /// Region represent the signing region of this endpoint.
+ ///
+ /// Required if endpoint is not provided.
+ ///
+ /// - If region is set, we will take user's input first.
+ /// - If not, we will try to load it from environment.
+ /// - If still not set, default to `cn-beijing`.
+ pub region: Option<String>,
+ /// access_key_id of this backend.
+ ///
+ /// - If access_key_id is set, we will take user's input first.
+ /// - If not, we will try to load it from environment.
+ #[serde(alias = "tos_access_key_id", alias = "volcengine_access_key_id")]
+ pub access_key_id: Option<String>,
+ /// secret_access_key of this backend.
+ ///
+ /// - If secret_access_key is set, we will take user's input first.
+ /// - If not, we will try to load it from environment.
+ #[serde(
+ alias = "tos_secret_access_key",
+ alias = "volcengine_secret_access_key"
+ )]
+ pub secret_access_key: Option<String>,
+ /// security_token of this backend.
+ ///
+ /// This token will expire after sometime, it's recommended to set
security_token
+ /// by hand.
+ #[serde(alias = "tos_security_token", alias = "volcengine_session_token")]
+ pub security_token: Option<String>,
+ /// Disable config load so that opendal will not load config from
+ /// environment.
+ ///
+ /// For examples:
+ /// - envs like `TOS_ACCESS_KEY_ID`
+ pub disable_config_load: bool,
+ /// Allow anonymous will allow opendal to send request without signing
+ /// when credential is not loaded.
+ pub allow_anonymous: bool,
+ /// Enable bucket versioning for this backend.
+ ///
+ /// If set to true, OpenDAL will support versioned operations like list
with
+ /// versions, read with version, etc.
+ pub enable_versioning: bool,
+}
+
+impl Debug for TosConfig {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("TosConfig")
+ .field("root", &self.root)
+ .field("bucket", &self.bucket)
+ .field("endpoint", &self.endpoint)
+ .field("region", &self.region)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Configurator for TosConfig {
+ type Builder = TosBuilder;
+
+ fn from_uri(uri: &OperatorUri) -> Result<Self> {
+ let mut map = uri.options().clone();
+
+ if let Some(name) = uri.name() {
+ map.insert("bucket".to_string(), name.to_string());
+ }
+
+ if let Some(root) = uri.root() {
+ map.insert("root".to_string(), root.to_string());
+ }
+
+ Self::from_iter(map)
+ }
+
+ fn into_builder(self) -> Self::Builder {
+ TosBuilder { config: self }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::iter;
+
+ use super::*;
+ use opendal_core::Configurator;
+ use opendal_core::OperatorUri;
+
+ #[test]
+ fn test_tos_config_original_field_names() {
+ let json = r#"{
+ "bucket": "test-bucket",
+ "access_key_id": "test-key",
+ "secret_access_key": "test-secret",
+ "region": "cn-beijing",
+ "endpoint": "https://tos-cn-beijing.volces.com"
+ }"#;
+
+ let config: TosConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.bucket, "test-bucket");
+ assert_eq!(config.access_key_id, Some("test-key".to_string()));
+ assert_eq!(config.secret_access_key, Some("test-secret".to_string()));
+ assert_eq!(config.region, Some("cn-beijing".to_string()));
+ assert_eq!(
+ config.endpoint,
+ Some("https://tos-cn-beijing.volces.com".to_string())
+ );
+ }
+
+ #[test]
+ fn test_tos_config_tos_prefixed_aliases() {
+ let json = r#"{
+ "tos_access_key_id": "test-key",
+ "tos_secret_access_key": "test-secret",
+ "tos_security_token": "test-token"
+ }"#;
+
+ let config: TosConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.access_key_id, Some("test-key".to_string()));
+ assert_eq!(config.secret_access_key, Some("test-secret".to_string()));
+ assert_eq!(config.security_token, Some("test-token".to_string()));
+ }
+
+ #[test]
+ fn test_tos_config_volcengine_prefixed_aliases() {
+ let json = r#"{
+ "volcengine_access_key_id": "test-key",
+ "volcengine_secret_access_key": "test-secret",
+ "volcengine_session_token": "test-token"
+ }"#;
+
+ let config: TosConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.access_key_id, Some("test-key".to_string()));
+ assert_eq!(config.secret_access_key, Some("test-secret".to_string()));
+ assert_eq!(config.security_token, Some("test-token".to_string()));
+ }
+
+ #[test]
+ fn from_uri_extracts_bucket_and_root() {
+ let uri = OperatorUri::new("tos://example-bucket/path/to/root",
iter::empty()).unwrap();
+ let cfg = TosConfig::from_uri(&uri).unwrap();
+ assert_eq!(cfg.bucket, "example-bucket");
+ assert_eq!(cfg.root.as_deref(), Some("path/to/root"));
+ }
+
+ #[test]
+ fn from_uri_extracts_endpoint() {
+ let uri = OperatorUri::new(
+
"tos://example-bucket/path/to/root?endpoint=https%3A%2F%2Fcustom-tos-endpoint.com",
+ iter::empty(),
+ )
+ .unwrap();
+ let cfg = TosConfig::from_uri(&uri).unwrap();
+ assert_eq!(cfg.bucket, "example-bucket");
+ assert_eq!(cfg.root.as_deref(), Some("path/to/root"));
+ assert_eq!(
+ cfg.endpoint.as_deref(),
+ Some("https://custom-tos-endpoint.com")
+ );
+ }
+}
diff --git a/core/services/tos/src/core.rs b/core/services/tos/src/core.rs
new file mode 100644
index 000000000..220957f92
--- /dev/null
+++ b/core/services/tos/src/core.rs
@@ -0,0 +1,39 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::Debug;
+use std::sync::Arc;
+
+use opendal_core::raw::*;
+
+pub struct TosCore {
+ pub info: Arc<AccessorInfo>,
+
+ pub bucket: String,
+ pub endpoint: String, // full endpoint with scheme, e.g.
https://tos-cn-beijing.volces.com
+ pub root: String,
+}
+
+impl Debug for TosCore {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("TosCore")
+ .field("bucket", &self.bucket)
+ .field("endpoint", &self.endpoint)
+ .field("root", &self.root)
+ .finish_non_exhaustive()
+ }
+}
diff --git a/core/services/tos/src/lib.rs b/core/services/tos/src/lib.rs
new file mode 100644
index 000000000..3216bc3e6
--- /dev/null
+++ b/core/services/tos/src/lib.rs
@@ -0,0 +1,35 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#![cfg_attr(docsrs, feature(doc_cfg))]
+//! Volcengine TOS service implementation for Apache OpenDAL.
+#![deny(missing_docs)]
+
+mod backend;
+mod config;
+mod core;
+
+pub use backend::TosBuilder as Tos;
+pub use config::TosConfig;
+
+/// Default scheme for TOS service.
+pub const TOS_SCHEME: &str = "tos";
+
+/// Register this service into the given registry.
+pub fn register_tos_service(registry: &opendal_core::OperatorRegistry) {
+ registry.register::<Tos>(TOS_SCHEME);
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index f3e7a3bc2..302fa88dc 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -205,6 +205,9 @@ fn init_default_registry_inner(registry: &OperatorRegistry)
{
#[cfg(feature = "services-tikv")]
opendal_service_tikv::register_tikv_service(registry);
+ #[cfg(feature = "services-tos")]
+ opendal_service_tos::register_tos_service(registry);
+
#[cfg(feature = "services-upyun")]
opendal_service_upyun::register_upyun_service(registry);