feat(nacos): enhance configuration management

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-26 11:20:05 +08:00
1 parent 406b7acdbb
commit 1ebc3fd12b
52 files changed
+7139 -263

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
use zip::write::SimpleFileOptions;
use crate::nacos::types::{NacosConfigItem, NacosConfigUpsert};
pub const MAX_ARCHIVE_BYTES: u64 = 100 * 1024 * 1024;
pub const MAX_UNCOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_CONFIG_BYTES: u64 = 20 * 1024 * 1024;
pub const MAX_METADATA_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_CONFIG_ITEMS: usize = 10_000;
const METADATA_PATH: &str = ".metadata.yml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct MetadataItem {
group: String,
#[serde(rename = "dataId")]
data_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
config_type: Option<String>,
#[serde(rename = "appName", default, skip_serializing_if = "Option::is_none")]
app_name: Option<String>,
#[serde(rename = "configTags", default, skip_serializing_if = "Option::is_none")]
config_tags: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MetadataDocument {
metadata: Vec<MetadataItem>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum CompatibleMetadata {
Document(MetadataDocument),
Items(Vec<MetadataItem>),
}
impl CompatibleMetadata {
fn into_items(self) -> Vec<MetadataItem> {
match self {
Self::Document(document) => document.metadata,
Self::Items(items) => items,
}
}
}
pub fn encode_config_archive(configs: &[NacosConfigItem]) -> Result<Vec<u8>, String> {
if configs.len() > MAX_CONFIG_ITEMS {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut sorted = configs.to_vec();
sorted.sort_by(|left, right| {
(&left.group, &left.data_id, &left.namespace).cmp(&(&right.group, &right.data_id, &right.namespace))
});
let mut seen = HashSet::new();
let mut metadata = Vec::with_capacity(sorted.len());
let mut actual_uncompressed = 0u64;
for config in &sorted {
validate_config_key(&config.group, &config.data_id)?;
if !seen.insert((config.group.clone(), config.data_id.clone())) {
return Err(format!("Duplicate Nacos configuration key: {}/{}", config.group, config.data_id));
}
let content = config
.content
.as_deref()
.ok_or_else(|| format!("Nacos configuration {}/{} has no content", config.group, config.data_id))?;
if content.len() as u64 > MAX_CONFIG_BYTES {
return Err(format!("Nacos configuration {}/{} exceeds the 20 MiB limit", config.group, config.data_id));
}
actual_uncompressed = add_uncompressed_size(actual_uncompressed, content.len() as u64)?;
metadata.push(MetadataItem {
group: config.group.clone(),
data_id: config.data_id.clone(),
desc: config.desc.clone(),
config_type: config.config_type.clone(),
app_name: config.app_name.clone(),
config_tags: config.tags.clone(),
});
}
let metadata = serde_yaml_ng::to_string(&MetadataDocument { metadata })
.map_err(|error| format!("Failed to encode Nacos archive metadata: {error}"))?;
if metadata.len() as u64 > MAX_METADATA_BYTES {
return Err("Nacos archive metadata exceeds the 2 MiB limit".to_string());
}
add_uncompressed_size(actual_uncompressed, metadata.len() as u64)?;
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).unix_permissions(0o600);
archive
.start_file(METADATA_PATH, options)
.map_err(|error| format!("Failed to create Nacos archive metadata entry: {error}"))?;
archive
.write_all(metadata.as_bytes())
.map_err(|error| format!("Failed to write Nacos archive metadata: {error}"))?;
for config in sorted {
archive
.start_file(archive_entry_path(&config.group, &config.data_id), options)
.map_err(|error| format!("Failed to create Nacos archive entry: {error}"))?;
archive
.write_all(config.content.as_deref().unwrap_or_default().as_bytes())
.map_err(|error| format!("Failed to write Nacos archive entry: {error}"))?;
}
let bytes = archive.finish().map_err(|error| format!("Failed to finish Nacos archive: {error}"))?.into_inner();
if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
Ok(bytes)
}
pub fn decode_config_archive(bytes: &[u8], target_namespace: &str) -> Result<Vec<NacosConfigUpsert>, String> {
if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| format!("Invalid Nacos ZIP archive: {error}"))?;
if archive.len() > MAX_CONFIG_ITEMS.saturating_add(1) {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut entries = HashMap::<String, Vec<u8>>::new();
let mut actual_uncompressed = 0u64;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|error| format!("Invalid Nacos ZIP entry: {error}"))?;
let name = entry.name().to_string();
validate_archive_path(&name)?;
if entry.is_dir() {
continue;
}
if entry.unix_mode().is_some_and(|mode| mode & 0o170000 == 0o120000) {
return Err(format!("Nacos archive symlink is not allowed: {name}"));
}
let declared_limit = if name == METADATA_PATH { MAX_METADATA_BYTES } else { MAX_CONFIG_BYTES };
if entry.size() > declared_limit {
return Err(format!("Nacos archive entry exceeds its size limit: {name}"));
}
let mut content = Vec::with_capacity(entry.size().min(declared_limit) as usize);
entry
.by_ref()
.take(declared_limit + 1)
.read_to_end(&mut content)
.map_err(|error| format!("Failed to read Nacos archive entry {name}: {error}"))?;
if content.len() as u64 > declared_limit {
return Err(format!("Nacos archive entry exceeds its size limit: {name}"));
}
actual_uncompressed = add_uncompressed_size(actual_uncompressed, content.len() as u64)?;
if entries.insert(name.clone(), content).is_some() {
return Err(format!("Duplicate Nacos archive path: {name}"));
}
}
let metadata = entries.remove(METADATA_PATH).ok_or_else(|| "Nacos archive is missing .metadata.yml".to_string())?;
let metadata: CompatibleMetadata =
serde_yaml_ng::from_slice(&metadata).map_err(|error| format!("Invalid Nacos archive metadata: {error}"))?;
let metadata = metadata.into_items();
if metadata.len() > MAX_CONFIG_ITEMS {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut keys = HashSet::new();
let mut configs = Vec::with_capacity(metadata.len());
for item in metadata {
validate_config_key(&item.group, &item.data_id)?;
if !keys.insert((item.group.clone(), item.data_id.clone())) {
return Err(format!("Duplicate Nacos configuration key: {}/{}", item.group, item.data_id));
}
let path = archive_entry_path(&item.group, &item.data_id);
// Archives exported by older DBX versions and the Nacos console use
// the direct `group/dataId` layout. Keep accepting it as a fallback;
// new exports only use that layout when both components are safe.
let legacy_path = format!("{}/{}", item.group, item.data_id);
let content = entries
.remove(&path)
.or_else(|| entries.remove(&legacy_path))
.ok_or_else(|| format!("Nacos archive metadata references a missing file: {path}"))?;
let content =
String::from_utf8(content).map_err(|_| format!("Nacos configuration is not valid UTF-8: {path}"))?;
configs.push(NacosConfigUpsert {
namespace: Some(target_namespace.to_string()),
data_id: item.data_id,
group: item.group,
content,
config_type: item.config_type,
app_name: item.app_name,
desc: item.desc,
tags: item.config_tags,
});
}
if let Some(orphan) = entries.keys().next() {
return Err(format!("Nacos archive contains an orphan file not present in metadata: {orphan}"));
}
Ok(configs)
}
fn validate_archive_path(name: &str) -> Result<(), String> {
let bytes = name.as_bytes();
let has_windows_drive_prefix =
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\');
if name.is_empty()
|| name.contains('\0')
|| name.contains('\\')
|| name.starts_with('/')
|| has_windows_drive_prefix
{
return Err(format!("Unsafe Nacos archive path: {name}"));
}
let path = Path::new(name);
if path.components().any(|component| !matches!(component, Component::Normal(_))) {
return Err(format!("Unsafe Nacos archive path: {name}"));
}
Ok(())
}
fn add_uncompressed_size(current: u64, addition: u64) -> Result<u64, String> {
let total = current.checked_add(addition).ok_or_else(|| "Nacos archive uncompressed size overflow".to_string())?;
if total > MAX_UNCOMPRESSED_BYTES {
return Err("Nacos archive exceeds the 256 MiB uncompressed limit".to_string());
}
Ok(total)
}
fn validate_config_key(group: &str, data_id: &str) -> Result<(), String> {
for (label, value) in [("group", group), ("dataId", data_id)] {
if value.trim().is_empty() || value.contains('\0') {
return Err(format!("Invalid Nacos configuration {label}: {value:?}"));
}
}
Ok(())
}
fn archive_entry_path(group: &str, data_id: &str) -> String {
if is_safe_archive_component(group) && is_safe_archive_component(data_id) {
return format!("{group}/{data_id}");
}
// Metadata is the source of truth for the original identifiers. Hex keeps
// the ZIP entry path portable and collision-free without treating Nacos
// identifiers as filesystem paths.
format!("configs/{}--{}", hex_encode(group.as_bytes()), hex_encode(data_id.as_bytes()))
}
fn is_safe_archive_component(value: &str) -> bool {
!value.is_empty() && value != "." && value != ".." && !value.contains(['/', '\\', '\0'])
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
fn config(group: &str, data_id: &str, content: &str) -> NacosConfigItem {
NacosConfigItem {
data_id: data_id.to_string(),
group: group.to_string(),
namespace: "source".to_string(),
app_name: Some("dbx".to_string()),
desc: Some("test".to_string()),
tags: Some("one,two".to_string()),
config_type: Some("yaml".to_string()),
md5: None,
encrypted_data_key: Some("must-not-leak".to_string()),
content: Some(content.to_string()),
}
}
#[test]
fn archive_round_trip_preserves_migratable_fields_only() {
let bytes = encode_config_archive(&[config("DEFAULT_GROUP", "app.yaml", "数据库: mysql://db")]).unwrap();
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].namespace.as_deref(), Some("target"));
assert_eq!(decoded[0].content, "数据库: mysql://db");
assert_eq!(decoded[0].tags.as_deref(), Some("one,two"));
}
#[test]
fn rejects_path_traversal() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file("../evil", SimpleFileOptions::default()).unwrap();
zip.write_all(b"bad").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let error = decode_config_archive(&bytes, "target").unwrap_err();
assert!(error.contains("Unsafe"));
}
#[test]
fn rejects_windows_absolute_path() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file("C:/secret", SimpleFileOptions::default()).unwrap();
zip.write_all(b"bad").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let error = decode_config_archive(&bytes, "target").unwrap_err();
assert!(error.contains("Unsafe"));
}
#[test]
fn enforces_total_uncompressed_limit_arithmetically() {
assert_eq!(add_uncompressed_size(MAX_UNCOMPRESSED_BYTES - 1, 1).unwrap(), MAX_UNCOMPRESSED_BYTES);
assert!(add_uncompressed_size(MAX_UNCOMPRESSED_BYTES, 1).unwrap_err().contains("256 MiB"));
}
#[test]
fn accepts_official_list_shaped_metadata() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file(METADATA_PATH, SimpleFileOptions::default()).unwrap();
zip.write_all(b"- group: DEFAULT_GROUP\n dataId: app.properties\n type: properties\n").unwrap();
zip.start_file("DEFAULT_GROUP/app.properties", SimpleFileOptions::default()).unwrap();
zip.write_all(b"a=1").unwrap();
let bytes = zip.finish().unwrap().into_inner();
assert_eq!(decode_config_archive(&bytes, "").unwrap()[0].content, "a=1");
}
#[test]
fn archive_round_trip_preserves_identifiers_with_path_separators() {
let bytes = encode_config_archive(&[config("team/dev", "apps/service.yaml", "enabled: true")]).unwrap();
let archive = zip::ZipArchive::new(Cursor::new(bytes.clone())).unwrap();
assert!(archive.file_names().any(|name| name.starts_with("configs/")));
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded[0].group, "team/dev");
assert_eq!(decoded[0].data_id, "apps/service.yaml");
}
#[test]
fn accepts_legacy_nested_path_for_separator_identifiers() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file(METADATA_PATH, SimpleFileOptions::default()).unwrap();
zip.write_all(b"- group: team/dev\n dataId: apps/service.yaml\n").unwrap();
zip.start_file("team/dev/apps/service.yaml", SimpleFileOptions::default()).unwrap();
zip.write_all(b"enabled: true").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded[0].group, "team/dev");
assert_eq!(decoded[0].data_id, "apps/service.yaml");
}
}
+787
View File
@@ -0,0 +1,787 @@
use std::path::Path;
use std::sync::Arc;
use futures::{stream, StreamExt};
use sha2::{Digest, Sha256};
use crate::connection::AppState;
use crate::nacos::archive::{decode_config_archive, encode_config_archive, MAX_ARCHIVE_BYTES};
use crate::nacos::port::NacosAdmin;
use crate::nacos::search::{begin_operation, finish_operation};
use crate::nacos::service::{ensure_connection_writable, get_admin};
use crate::nacos::types::{
NacosBatchItemResult, NacosBatchPreview, NacosBatchPreviewItem, NacosBatchReport, NacosConfigItem, NacosConfigKey,
NacosConfigSelectionScope, NacosConfigSelector, NacosConfigTransferRequest, NacosConfigUpsert, NacosConflictPolicy,
};
const PAGE_SIZE: u32 = 500;
const DETAIL_CONCURRENCY: usize = 8;
pub async fn export_config_archive(
admin: Arc<dyn NacosAdmin>,
selector: NacosConfigSelector,
destination: &Path,
) -> Result<NacosBatchReport, String> {
let configs = resolve_selector(admin, &selector).await?;
let (configs, bytes) = tokio::task::spawn_blocking(move || {
let bytes = encode_config_archive(&configs)?;
Ok::<_, String>((configs, bytes))
})
.await
.map_err(|error| format!("Failed to encode Nacos configuration archive: {error}"))??;
tokio::fs::write(destination, bytes)
.await
.map_err(|error| format!("Failed to write Nacos configuration archive {}: {error}", destination.display()))?;
let items = configs
.iter()
.map(|config| NacosBatchItemResult {
namespace: config.namespace.clone(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: "exported".to_string(),
message: None,
})
.collect();
Ok(NacosBatchReport {
operation_id: uuid::Uuid::new_v4().to_string(),
plan_hash: None,
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: 0,
failed: 0,
aborted: false,
partial: false,
cancelled: false,
items,
})
}
pub async fn preview_import(
admin: Arc<dyn NacosAdmin>,
target_namespace: &str,
archive_path: &Path,
) -> Result<NacosBatchPreview, String> {
let configs = read_archive(archive_path, target_namespace).await?;
preview_configs(admin, &configs).await
}
pub async fn apply_import(
admin: Arc<dyn NacosAdmin>,
target_namespace: &str,
archive_path: &Path,
operation_id: &str,
plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
let configs = read_archive(archive_path, target_namespace).await?;
apply_configs(admin, configs, operation_id, plan_hash, policy).await
}
pub async fn preview_transfer(
source_admin: Arc<dyn NacosAdmin>,
target_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
) -> Result<NacosBatchPreview, String> {
let configs = transfer_configs(source_admin, request).await?;
preview_configs(target_admin, &configs).await
}
pub async fn apply_transfer(
source_admin: Arc<dyn NacosAdmin>,
target_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
plan_hash: &str,
) -> Result<NacosBatchReport, String> {
let configs = transfer_configs(source_admin, request).await?;
apply_configs(target_admin, configs, &request.operation_id, plan_hash, &request.conflict_policy).await
}
pub async fn nacos_export_config_archive_core(
state: &AppState,
conn_id: &str,
selector: NacosConfigSelector,
destination: &Path,
) -> Result<NacosBatchReport, String> {
let admin = get_admin(state, conn_id).await?;
export_config_archive(admin, selector, destination).await
}
pub async fn nacos_preview_config_import_core(
state: &AppState,
conn_id: &str,
target_namespace: &str,
archive_path: &Path,
) -> Result<NacosBatchPreview, String> {
ensure_connection_writable(state, conn_id, "Import Nacos configurations").await?;
let admin = get_admin(state, conn_id).await?;
preview_import(admin, target_namespace, archive_path).await
}
pub async fn nacos_apply_config_import_core(
state: &AppState,
conn_id: &str,
target_namespace: &str,
archive_path: &Path,
operation_id: &str,
plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
ensure_connection_writable(state, conn_id, "Import Nacos configurations").await?;
let admin = get_admin(state, conn_id).await?;
apply_import(admin, target_namespace, archive_path, operation_id, plan_hash, policy).await
}
pub async fn nacos_preview_config_transfer_core(
state: &AppState,
request: &NacosConfigTransferRequest,
) -> Result<NacosBatchPreview, String> {
ensure_connection_writable(state, &request.target_connection_id, "Copy Nacos configurations").await?;
let source_admin = get_admin(state, &request.source_connection_id).await?;
let target_admin = get_admin(state, &request.target_connection_id).await?;
preview_transfer(source_admin, target_admin, request).await
}
pub async fn nacos_apply_config_transfer_core(
state: &AppState,
request: &NacosConfigTransferRequest,
plan_hash: &str,
) -> Result<NacosBatchReport, String> {
ensure_connection_writable(state, &request.target_connection_id, "Copy Nacos configurations").await?;
let source_admin = get_admin(state, &request.source_connection_id).await?;
let target_admin = get_admin(state, &request.target_connection_id).await?;
apply_transfer(source_admin, target_admin, request, plan_hash).await
}
async fn transfer_configs(
source_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
) -> Result<Vec<NacosConfigUpsert>, String> {
let source = resolve_selector(source_admin, &request.source).await?;
source
.into_iter()
.map(|config| {
let content = config
.content
.ok_or_else(|| format!("Nacos configuration {}/{} has no content", config.group, config.data_id))?;
Ok(NacosConfigUpsert {
namespace: Some(request.target_namespace.clone()),
data_id: config.data_id,
group: config.group,
content,
config_type: config.config_type,
app_name: config.app_name,
desc: config.desc,
tags: config.tags,
})
})
.collect()
}
async fn resolve_selector(
admin: Arc<dyn NacosAdmin>,
selector: &NacosConfigSelector,
) -> Result<Vec<NacosConfigItem>, String> {
let keys = match selector.scope {
NacosConfigSelectionScope::Selected => selector
.keys
.iter()
.map(|key| NacosConfigKey {
namespace: Some(selector.namespace.clone()),
data_id: key.data_id.clone(),
group: key.group.clone(),
})
.collect(),
NacosConfigSelectionScope::Filtered | NacosConfigSelectionScope::Namespace => {
let mut query = selector.query.clone().unwrap_or(crate::nacos::types::NacosConfigQuery {
namespace: None,
group: None,
data_id: None,
app_name: None,
search: None,
page_no: None,
page_size: None,
});
query.namespace = Some(selector.namespace.clone());
if matches!(selector.scope, NacosConfigSelectionScope::Namespace) {
query.group = None;
query.data_id = None;
query.app_name = None;
query.search = None;
}
let mut keys = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut page_no = 1;
loop {
query.page_no = Some(page_no);
query.page_size = Some(PAGE_SIZE);
let page = admin.list_configs(query.clone()).await?;
let total = page.total_count;
let empty = page.items.is_empty();
let before = keys.len();
keys.extend(page.items.into_iter().filter_map(|item| {
let key = NacosConfigKey {
namespace: Some(selector.namespace.clone()),
data_id: item.data_id,
group: item.group,
};
seen.insert((key.namespace.clone(), key.group.clone(), key.data_id.clone())).then_some(key)
}));
if empty || total == 0 || keys.len() as u64 >= total {
break;
}
if keys.len() == before {
return Err(
"Nacos configuration pagination made no progress; the server repeated a page".to_string()
);
}
page_no = page_no
.checked_add(1)
.ok_or_else(|| "Nacos configuration pagination exceeded the supported page range".to_string())?;
}
keys
}
};
let mut seen = std::collections::HashSet::new();
let keys: Vec<_> = keys
.into_iter()
.filter(|key| seen.insert((key.namespace.clone(), key.group.clone(), key.data_id.clone())))
.collect();
let details = stream::iter(keys)
.map(|key| {
let admin = admin.clone();
async move { admin.get_config(key).await }
})
.buffer_unordered(DETAIL_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut configs = details.into_iter().collect::<Result<Vec<_>, _>>()?;
configs.sort_by(|left, right| {
(&left.namespace, &left.group, &left.data_id).cmp(&(&right.namespace, &right.group, &right.data_id))
});
Ok(configs)
}
async fn preview_configs(
admin: Arc<dyn NacosAdmin>,
configs: &[NacosConfigUpsert],
) -> Result<NacosBatchPreview, String> {
let mut items = Vec::with_capacity(configs.len());
let mut target_snapshots = Vec::with_capacity(configs.len());
let mut created = 0;
let mut conflicts = 0;
for config in configs {
let target = existing_config(admin.as_ref(), config).await?;
let status = if target.is_some() {
conflicts += 1;
"conflict"
} else {
created += 1;
"create"
};
target_snapshots.push(target);
items.push(NacosBatchPreviewItem {
namespace: config.namespace.clone().unwrap_or_default(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: status.to_string(),
message: None,
});
}
Ok(NacosBatchPreview {
plan_hash: plan_hash(configs, &target_snapshots),
total: configs.len() as u64,
created,
conflicts,
invalid: 0,
items,
})
}
async fn apply_configs(
admin: Arc<dyn NacosAdmin>,
configs: Vec<NacosConfigUpsert>,
operation_id: &str,
expected_plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
let preview = preview_configs(admin.clone(), &configs).await?;
if preview.plan_hash != expected_plan_hash {
return Err(
"NACOS_ERROR[stalePreview]: Nacos import/copy preview is stale; preview again before applying".to_string()
);
}
if matches!(policy, NacosConflictPolicy::Abort) && preview.conflicts > 0 {
return Ok(NacosBatchReport {
operation_id: operation_id.to_string(),
plan_hash: Some(preview.plan_hash),
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: configs.len() as u64,
failed: 0,
aborted: true,
partial: false,
cancelled: false,
items: preview
.items
.into_iter()
.map(|item| NacosBatchItemResult {
namespace: item.namespace,
group: item.group,
data_id: item.data_id,
status: "aborted".to_string(),
message: Some("Conflict policy ABORT prevented all writes".to_string()),
})
.collect(),
});
}
let token = begin_operation(operation_id)?;
struct Guard(String);
impl Drop for Guard {
fn drop(&mut self) {
finish_operation(&self.0);
}
}
let _guard = Guard(operation_id.to_string());
let mut report = NacosBatchReport {
operation_id: operation_id.to_string(),
plan_hash: Some(preview.plan_hash),
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: 0,
failed: 0,
aborted: false,
partial: false,
cancelled: false,
items: Vec::with_capacity(configs.len()),
};
for (config, preview_item) in configs.into_iter().zip(preview.items) {
if token.is_cancelled() {
report.cancelled = true;
break;
}
let conflict = preview_item.status == "conflict";
if conflict && matches!(policy, NacosConflictPolicy::Skip) {
report.skipped += 1;
report.items.push(batch_result(&config, "skipped", None));
continue;
}
match admin.publish_config(config.clone()).await {
Ok(()) if conflict => {
report.overwritten += 1;
report.items.push(batch_result(&config, "overwritten", None));
}
Ok(()) => {
report.created += 1;
report.items.push(batch_result(&config, "created", None));
}
Err(error) => {
report.failed += 1;
report.items.push(batch_result(&config, "failed", Some(error)));
}
}
}
report.partial =
report.cancelled || report.failed > 0 || report.created + report.overwritten + report.skipped < report.total;
Ok(report)
}
async fn existing_config(
admin: &dyn NacosAdmin,
config: &NacosConfigUpsert,
) -> Result<Option<NacosConfigItem>, String> {
let namespace = config.namespace.clone().unwrap_or_default();
let page = admin
.list_configs(crate::nacos::types::NacosConfigQuery {
namespace: Some(namespace.clone()),
group: Some(config.group.clone()),
data_id: Some(config.data_id.clone()),
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(PAGE_SIZE),
})
.await?;
let exists = page.items.iter().any(|item| item.group == config.group && item.data_id == config.data_id);
if !exists {
return Ok(None);
}
admin
.get_config(NacosConfigKey {
namespace: Some(namespace),
data_id: config.data_id.clone(),
group: config.group.clone(),
})
.await
.map(Some)
}
fn plan_hash(configs: &[NacosConfigUpsert], targets: &[Option<NacosConfigItem>]) -> String {
let mut digest = Sha256::new();
digest.update(b"dbx-nacos-batch-plan-v1");
let mut entries = configs.iter().zip(targets.iter()).collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| {
(left.namespace.as_deref().unwrap_or_default(), left.group.as_str(), left.data_id.as_str()).cmp(&(
right.namespace.as_deref().unwrap_or_default(),
right.group.as_str(),
right.data_id.as_str(),
))
});
for (config, target) in entries {
hash_field(&mut digest, config.namespace.as_deref().unwrap_or_default());
hash_field(&mut digest, &config.group);
hash_field(&mut digest, &config.data_id);
hash_field(&mut digest, &config.content);
hash_field(&mut digest, config.config_type.as_deref().unwrap_or_default());
hash_field(&mut digest, config.app_name.as_deref().unwrap_or_default());
hash_field(&mut digest, config.desc.as_deref().unwrap_or_default());
hash_field(&mut digest, config.tags.as_deref().unwrap_or_default());
if let Some(target) = target {
digest.update([1]);
hash_field(&mut digest, target.md5.as_deref().unwrap_or_default());
hash_field(&mut digest, target.content.as_deref().unwrap_or_default());
} else {
digest.update([0]);
}
}
digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hash_field(digest: &mut Sha256, value: &str) {
digest.update((value.len() as u64).to_be_bytes());
digest.update(value.as_bytes());
}
fn batch_result(config: &NacosConfigUpsert, status: &str, message: Option<String>) -> NacosBatchItemResult {
NacosBatchItemResult {
namespace: config.namespace.clone().unwrap_or_default(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: status.to_string(),
message,
}
}
async fn read_archive(path: &Path, target_namespace: &str) -> Result<Vec<NacosConfigUpsert>, String> {
let path = path.to_path_buf();
let target_namespace = target_namespace.to_string();
tokio::task::spawn_blocking(move || {
let metadata = std::fs::metadata(&path)
.map_err(|error| format!("Failed to inspect Nacos archive {}: {error}", path.display()))?;
if metadata.len() > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
let bytes = std::fs::read(&path)
.map_err(|error| format!("Failed to read Nacos archive {}: {error}", path.display()))?;
decode_config_archive(&bytes, &target_namespace)
})
.await
.map_err(|error| format!("Failed to process Nacos configuration archive: {error}"))?
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::RwLock;
use async_trait::async_trait;
use super::*;
use crate::nacos::types::*;
#[derive(Default)]
struct MockAdmin {
configs: RwLock<HashMap<(String, String, String), NacosConfigItem>>,
failed_data_ids: RwLock<HashSet<String>>,
publish_count: AtomicUsize,
}
impl MockAdmin {
fn insert(&self, item: NacosConfigItem) {
self.configs
.write()
.unwrap()
.insert((item.namespace.clone(), item.group.clone(), item.data_id.clone()), item);
}
fn fail_publish(&self, data_id: &str) {
self.failed_data_ids.write().unwrap().insert(data_id.to_string());
}
}
#[async_trait]
impl NacosAdmin for MockAdmin {
async fn test_connection(&self) -> Result<NacosConnectionInfo, String> {
Err("unused".to_string())
}
async fn list_namespaces(&self) -> Result<Vec<NacosNamespaceInfo>, String> {
Err("unused".to_string())
}
async fn create_namespace(&self, _: NacosNamespaceCreate) -> Result<(), String> {
Err("unused".to_string())
}
async fn update_namespace(&self, _: NacosNamespaceUpdate) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String> {
let namespace = query.namespace.unwrap_or_default();
let items: Vec<_> = self
.configs
.read()
.unwrap()
.values()
.filter(|item| item.namespace == namespace)
.filter(|item| query.group.as_ref().is_none_or(|group| item.group == *group))
.filter(|item| query.data_id.as_ref().is_none_or(|data_id| item.data_id == *data_id))
.cloned()
.collect();
Ok(NacosConfigList {
page_no: query.page_no.unwrap_or(1),
page_size: query.page_size.unwrap_or(PAGE_SIZE),
total_count: items.len() as u64,
items,
})
}
async fn search_config_content_page(
&self,
_: &str,
_: &str,
_: u32,
_: u32,
) -> Result<Option<NacosConfigList>, String> {
Err("unused".to_string())
}
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String> {
self.configs
.read()
.unwrap()
.get(&(key.namespace.unwrap_or_default(), key.group, key.data_id))
.cloned()
.ok_or_else(|| "not found".to_string())
}
async fn publish_config(&self, req: NacosConfigUpsert) -> Result<(), String> {
if self.failed_data_ids.read().unwrap().contains(&req.data_id) {
return Err("simulated publish failure".to_string());
}
self.publish_count.fetch_add(1, Ordering::SeqCst);
let namespace = req.namespace.unwrap_or_default();
self.insert(NacosConfigItem {
data_id: req.data_id,
group: req.group,
namespace,
app_name: req.app_name,
desc: req.desc,
tags: req.tags,
config_type: req.config_type,
md5: None,
encrypted_data_key: None,
content: Some(req.content),
});
Ok(())
}
async fn delete_config(&self, _: NacosConfigKey) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_config_history(&self, _: NacosConfigHistoryQuery) -> Result<NacosConfigHistoryList, String> {
Err("unused".to_string())
}
async fn get_config_history(&self, _: NacosConfigHistoryKey) -> Result<NacosConfigItem, String> {
Err("unused".to_string())
}
async fn rollback_config(&self, _: NacosConfigRollbackRequest) -> Result<(), String> {
Err("unused".to_string())
}
async fn get_rnacos_console_captcha(&self) -> Result<NacosRNacosConsoleCaptcha, String> {
Err("unused".to_string())
}
async fn login_rnacos_console(&self, _: Option<String>) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_services(&self, _: NacosServiceQuery) -> Result<NacosServiceList, String> {
Err("unused".to_string())
}
async fn list_instances(&self, _: NacosInstanceQuery) -> Result<Vec<NacosInstanceInfo>, String> {
Err("unused".to_string())
}
async fn update_instance(&self, _: NacosInstanceUpdate) -> Result<(), String> {
Err("unused".to_string())
}
async fn raw_request(&self, _: NacosRawRequest) -> Result<NacosRawResponse, String> {
Err("unused".to_string())
}
}
fn upsert(data_id: &str, content: &str) -> NacosConfigUpsert {
NacosConfigUpsert {
namespace: Some("target".to_string()),
data_id: data_id.to_string(),
group: "DEFAULT_GROUP".to_string(),
content: content.to_string(),
config_type: Some("text".to_string()),
app_name: None,
desc: None,
tags: None,
}
}
fn existing(data_id: &str, content: &str) -> NacosConfigItem {
NacosConfigItem {
data_id: data_id.to_string(),
group: "DEFAULT_GROUP".to_string(),
namespace: "target".to_string(),
app_name: None,
desc: None,
tags: None,
config_type: Some("text".to_string()),
md5: Some(format!("md5-{content}")),
encrypted_data_key: None,
content: Some(content.to_string()),
}
}
async fn preview_hash(admin: Arc<MockAdmin>, configs: &[NacosConfigUpsert]) -> String {
preview_configs(admin, configs).await.unwrap().plan_hash
}
#[tokio::test]
async fn stale_preview_is_rejected_before_any_publish() {
let admin = Arc::new(MockAdmin::default());
let configs = vec![upsert("app", "new")];
let hash = preview_hash(admin.clone(), &configs).await;
admin.insert(existing("app", "concurrent"));
let error = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap_err();
assert!(error.contains("stalePreview"));
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn apply_accepts_preview_when_concurrent_source_reads_complete_in_another_order() {
let admin = Arc::new(MockAdmin::default());
let preview_configs = vec![upsert("first", "one"), upsert("second", "two")];
let hash = preview_hash(admin.clone(), &preview_configs).await;
// `resolve_selector` fetches details concurrently, so the same selected
// configurations can reach apply in a different completion order.
let apply_configs_in_completion_order = vec![upsert("second", "two"), upsert("first", "one")];
let report = apply_configs(
admin.clone(),
apply_configs_in_completion_order,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!(report.created, 2);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn abort_policy_writes_nothing_when_any_conflict_exists() {
let admin = Arc::new(MockAdmin::default());
admin.insert(existing("conflict", "old"));
let configs = vec![upsert("conflict", "new"), upsert("fresh", "value")];
let hash = preview_hash(admin.clone(), &configs).await;
let report = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Abort,
)
.await
.unwrap();
assert!(report.aborted);
assert_eq!(report.skipped, 2);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn skip_and_overwrite_policies_report_per_item_outcomes() {
let skip_admin = Arc::new(MockAdmin::default());
skip_admin.insert(existing("conflict", "old"));
let configs = vec![upsert("conflict", "new"), upsert("fresh", "value")];
let skip_hash = preview_hash(skip_admin.clone(), &configs).await;
let skipped = apply_configs(
skip_admin.clone(),
configs.clone(),
&uuid::Uuid::new_v4().to_string(),
&skip_hash,
&NacosConflictPolicy::Skip,
)
.await
.unwrap();
assert_eq!((skipped.created, skipped.skipped, skipped.overwritten), (1, 1, 0));
assert_eq!(skip_admin.publish_count.load(Ordering::SeqCst), 1);
let overwrite_admin = Arc::new(MockAdmin::default());
overwrite_admin.insert(existing("conflict", "old"));
let overwrite_hash = preview_hash(overwrite_admin.clone(), &configs).await;
let overwritten = apply_configs(
overwrite_admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&overwrite_hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!((overwritten.created, overwritten.skipped, overwritten.overwritten), (1, 0, 1));
assert_eq!(overwrite_admin.publish_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn publish_failure_keeps_successes_and_marks_report_partial() {
let admin = Arc::new(MockAdmin::default());
admin.fail_publish("bad");
let configs = vec![upsert("good", "value"), upsert("bad", "value")];
let hash = preview_hash(admin.clone(), &configs).await;
let report = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!((report.created, report.failed), (1, 1));
assert!(report.partial);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 1);
}
}
+2 -1
View File
@@ -59,7 +59,8 @@ pub struct NacosAdminConfig {
pub context_path: String,
/// Optional r-nacos authenticated-console address. This is separate from
/// the OpenAPI server address because r-nacos exposes console-only APIs
/// (including config history) on its console service, normally port 10848.
/// (including config history plus config type and description metadata) on
/// its console service, normally port 10848.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rnacos_console_addr: String,
/// `None` preserves legacy records where supplying a console address
+651 -17
View File
@@ -239,7 +239,7 @@ impl NacosOpenApiAdmin {
fn rnacos_console_endpoint(&self, path: &str) -> Result<String, String> {
if self.cfg.rnacos_console_addr.is_empty() {
return Err(
"r-nacos config history requires an r-nacos console URL (the independent console service, normally port 10848)"
"r-nacos configuration metadata and history require an r-nacos console URL (the independent console service, normally port 10848)"
.to_string(),
);
}
@@ -273,7 +273,7 @@ impl NacosOpenApiAdmin {
if captcha.required {
return Err(classified_error(
"rnacosConsoleCaptchaRequired",
"r-nacos console requires a CAPTCHA before configuration history can be accessed",
"r-nacos console requires a CAPTCHA before configuration metadata or history can be accessed",
));
}
self.login_rnacos_console_with_captcha(None).await
@@ -386,6 +386,63 @@ impl NacosOpenApiAdmin {
}
}
async fn get_rnacos_console_json_without_login(
&self,
path: &str,
query: &[(String, String)],
) -> Result<Value, String> {
let response = self
.http
.get(self.rnacos_console_endpoint(path)?)
.query(query)
.send()
.await
.map_err(|e| format!("r-nacos console request to {path} failed: {e}"))?;
let response = error_for_status(response, path).await?;
let value = response_json_or_text(response).await?;
match value.get("success").and_then(Value::as_bool) {
Some(true) => Ok(value),
Some(false) => Err(format!("r-nacos console {path} failed: {}", rnacos_console_error_detail(&value))),
None => Err(format!(
"r-nacos console {path} returned an unexpected unauthenticated response instead of API JSON"
)),
}
}
/// Metadata reads also support `RNACOS_ENABLE_NO_AUTH_CONSOLE=true`.
/// Once an authenticated session exists, use it directly. Before login,
/// first try the endpoint without a token and only start the CAPTCHA-aware
/// login flow when the console actually rejects the anonymous request.
async fn get_rnacos_console_metadata_json(
&self,
path: &str,
query: Vec<(String, String)>,
) -> Result<Value, String> {
let has_valid_token = self
.rnacos_console_session
.lock()
.await
.token
.as_ref()
.is_some_and(|token| token.expires_at > Instant::now() + Duration::from_secs(30));
if has_valid_token {
return self.get_rnacos_console_json(path, query).await;
}
match self.get_rnacos_console_json_without_login(path, &query).await {
Ok(value) => Ok(value),
Err(anonymous_error) if self.cfg.has_effective_rnacos_console_credentials() => self
.get_rnacos_console_json(path, query)
.await
.map_err(|authenticated_error| {
format!(
"{authenticated_error}; anonymous r-nacos console metadata request also failed: {anonymous_error}"
)
}),
Err(error) => Err(error),
}
}
async fn list_rnacos_console_namespaces(&self) -> Result<Vec<NacosNamespaceInfo>, String> {
let value = self.get_rnacos_console_json("/rnacos/api/console/v2/namespaces/list", Vec::new()).await?;
Ok(parse_namespaces(value))
@@ -456,6 +513,57 @@ impl NacosOpenApiAdmin {
.await
}
/// r-nacos's Nacos-compatible configuration API returns the raw content
/// only. The independent console keeps the user-facing metadata such as
/// `configType` and `desc`, so enrich that compatibility response when a
/// console has been configured. Metadata is deliberately best-effort for
/// network/version failures. A CAPTCHA requirement is propagated so the UI
/// can authenticate once and retry the interrupted list/detail request.
async fn enrich_rnacos_config_metadata(&self, mut config: NacosConfigItem) -> Result<NacosConfigItem, String> {
if !self.is_explicit_rnacos()
|| (config.config_type.is_some() && config.desc.is_some())
|| self.cfg.rnacos_console_addr.is_empty()
{
return Ok(config);
}
let metadata = self
.get_rnacos_console_metadata_json(
"/rnacos/api/console/v2/config/info",
vec![
("tenant".to_string(), config.namespace.clone()),
("dataId".to_string(), config.data_id.clone()),
("group".to_string(), config.group.clone()),
],
)
.await
.map(|value| {
parse_config_detail(value, config.data_id.clone(), config.group.clone(), config.namespace.clone())
});
let metadata = match metadata {
Ok(metadata) => metadata,
Err(error) if error.contains("[rnacosConsoleCaptchaRequired]") => return Err(error),
// Metadata remains optional. Network, permission, or version
// mismatches must not hide configuration content obtained from
// the compatible OpenAPI.
Err(_) => return Ok(config),
};
if config.desc.is_none() {
config.desc = metadata.desc;
}
if config.config_type.is_none() {
config.config_type = metadata.config_type;
}
if config.md5.is_none() {
config.md5 = metadata.md5;
}
if config.content.is_none() {
config.content = metadata.content;
}
Ok(config)
}
async fn get_server_state(&self) -> Result<NacosServerStateProbe, String> {
let mut errors = Vec::new();
// r-nacos implements the Nacos client OpenAPI but not the console state endpoints.
@@ -607,21 +715,33 @@ impl NacosOpenApiAdmin {
let group = group.unwrap_or_default();
let app_name = app_name_filter.unwrap_or_default();
let scan_page_size = page_size.max(self.cfg.page_size).clamp(100, 500);
let max_scan_pages = 10;
let mut matched = Vec::new();
let mut seen = HashSet::new();
let mut current_page = 1;
while current_page <= max_scan_pages {
loop {
let value =
self.get_config_list_value(&namespace, "", &group, &app_name, current_page, scan_page_size).await?;
let list = parse_config_list(value, namespace.clone(), current_page, scan_page_size);
matched.extend(list.items.into_iter().filter(|item| item.data_id.to_lowercase().contains(&filter)));
let total_count = list.total_count;
let empty = list.items.is_empty();
let before = seen.len();
for item in list.items {
let identity = (item.namespace.clone(), item.group.clone(), item.data_id.clone());
if seen.insert(identity) && item.data_id.to_lowercase().contains(&filter) {
matched.push(item);
}
}
let scanned = u64::from(current_page) * u64::from(scan_page_size);
if scanned >= list.total_count || list.total_count == 0 {
if empty || total_count == 0 || seen.len() as u64 >= total_count {
break;
}
current_page += 1;
if seen.len() == before {
return Err("Nacos configuration pagination made no progress; the server repeated a page".to_string());
}
current_page = current_page
.checked_add(1)
.ok_or_else(|| "Nacos configuration pagination exceeded the supported page range".to_string())?;
}
let total_count = matched.len() as u64;
@@ -633,7 +753,12 @@ impl NacosOpenApiAdmin {
async fn enrich_missing_config_formats(&self, mut list: NacosConfigList) -> NacosConfigList {
for item in list.items.iter_mut() {
if item.config_type.is_some() {
// Normal Nacos lists already carry descriptions when available.
// r-nacos's compatibility list does not carry either the type or
// description, and its configured console can supply both.
let needs_rnacos_description =
self.is_explicit_rnacos() && item.desc.is_none() && !self.cfg.rnacos_console_addr.is_empty();
if item.config_type.is_some() && !needs_rnacos_description {
continue;
}
let detail = self
@@ -644,7 +769,12 @@ impl NacosOpenApiAdmin {
})
.await;
if let Ok(detail) = detail {
item.config_type = detail.config_type;
if item.config_type.is_none() {
item.config_type = detail.config_type;
}
if item.desc.is_none() {
item.desc = detail.desc;
}
}
}
list
@@ -713,6 +843,18 @@ fn qualified_nacos_service_name(service_name: &str, group_name: Option<&str>) ->
}
}
fn content_search_endpoint_is_unsupported(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("returned 404")
|| lower.contains("returned 405")
|| lower.contains("returned 410")
// Spring-based gateways and some Nacos distributions wrap an
// unmapped admin route as HTTP 500 instead of returning 404.
|| lower.contains("no static resource")
|| lower.contains("unsupported content search")
|| lower.contains("unsupportedcontentsearch")
}
#[async_trait]
impl NacosAdmin for NacosOpenApiAdmin {
async fn test_connection(&self) -> Result<NacosConnectionInfo, String> {
@@ -810,6 +952,9 @@ impl NacosAdmin for NacosOpenApiAdmin {
let mut v1_form =
vec![("namespaceName".to_string(), namespace_name), ("namespaceDesc".to_string(), namespace_desc)];
if let Some(namespace_id) = namespace_id {
// Nacos 3.x Console API uses `customNamespaceId`; passing only
// `namespaceId` is accepted but ignored and causes a generated UUID.
v3_form.push(("customNamespaceId".to_string(), namespace_id.clone()));
v3_form.push(("namespaceId".to_string(), namespace_id.clone()));
v1_form.push(("customNamespaceId".to_string(), namespace_id.clone()));
v1_form.push(("namespaceId".to_string(), namespace_id));
@@ -906,6 +1051,88 @@ impl NacosAdmin for NacosOpenApiAdmin {
Ok(parsed)
}
async fn search_config_content_page(
&self,
namespace: &str,
query: &str,
page_no: u32,
page_size: u32,
) -> Result<Option<NacosConfigList>, String> {
if self.is_explicit_rnacos() || matches!(self.cfg.version_mode, Some(NacosVersionMode::V2)) {
return Ok(None);
}
let page_no = page_no.max(1);
let page_size = page_size.clamp(1, 500);
// The native endpoint interprets `configDetail` using Nacos wildcard
// syntax. Callers only reach this fast path for wildcard-safe literal
// queries, so wrapping the term gives us contains semantics; every
// candidate is still fetched and verified with Rust `str::contains`.
let config_detail = format!("*{query}*");
let attempts = [
(
"/v3/admin/cs/config/list",
vec![
("configDetail".to_string(), config_detail.clone()),
("search".to_string(), "blur".to_string()),
("namespaceId".to_string(), namespace.to_string()),
("pageNo".to_string(), page_no.to_string()),
("pageSize".to_string(), page_size.to_string()),
],
),
(
"/v3/console/cs/config/searchDetail",
vec![
("configDetail".to_string(), config_detail),
("search".to_string(), "blur".to_string()),
("namespaceId".to_string(), namespace.to_string()),
("pageNo".to_string(), page_no.to_string()),
("pageSize".to_string(), page_size.to_string()),
],
),
];
let mut unsupported = false;
for (path, params) in attempts {
if !self.api_path_allowed(path) {
unsupported = true;
continue;
}
let response = match self.request(reqwest::Method::GET, path, params, None, None).await {
Ok(response) => response,
Err(error) if content_search_endpoint_is_unsupported(&error) => {
unsupported = true;
continue;
}
Err(error) => return Err(error),
};
let status = response.status();
if matches!(
status,
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED | reqwest::StatusCode::GONE
) {
unsupported = true;
continue;
}
let response = match error_for_status(response, path).await {
Ok(response) => response,
Err(error) if content_search_endpoint_is_unsupported(&error) => {
unsupported = true;
continue;
}
Err(error) => return Err(error),
};
let value = response_json_or_text(response).await?;
return Ok(Some(parse_config_list(value, namespace.to_string(), page_no, page_size)));
}
if unsupported {
Ok(None)
} else {
Err(classified_error(
"unsupportedContentSearch",
"No compatible Nacos content-search endpoint is available",
))
}
}
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String> {
let namespace = self.namespace(key.namespace.as_deref());
let v3_params = vec![
@@ -936,9 +1163,10 @@ impl NacosAdmin for NacosOpenApiAdmin {
let text =
resp.text().await.map_err(|e| format!("Failed to read Nacos config response: {e}"))?;
if let Ok(value) = serde_json::from_str::<Value>(&text) {
return Ok(parse_config_detail(value, key.data_id, key.group, namespace));
let detail = parse_config_detail(value, key.data_id, key.group, namespace);
return self.enrich_rnacos_config_metadata(detail).await;
}
return Ok(NacosConfigItem {
let detail = NacosConfigItem {
data_id: key.data_id,
group: key.group,
namespace,
@@ -949,11 +1177,13 @@ impl NacosAdmin for NacosOpenApiAdmin {
md5: None,
encrypted_data_key: None,
content: Some(text),
});
};
return self.enrich_rnacos_config_metadata(detail).await;
}
Ok(resp) => {
let value = response_json_or_text(resp).await?;
return Ok(parse_config_detail(value, key.data_id, key.group, namespace));
let detail = parse_config_detail(value, key.data_id, key.group, namespace);
return self.enrich_rnacos_config_metadata(detail).await;
}
Err(err) => errors.push(err),
},
@@ -1605,7 +1835,7 @@ fn parse_config_list(value: Value, namespace: String, page_no: u32, page_size: u
group: string_field(&item, &["group", "groupName"]),
namespace: string_field(&item, &["tenant", "namespaceId"]).if_empty(&namespace),
app_name: optional_string_field(&item, &["appName", "app_name"]),
desc: optional_string_field(&item, &["desc", "description"]),
desc: optional_string_field(&item, &["desc", "description", "configDesc", "config_desc"]),
tags: optional_string_field(&item, &["tags", "configTags", "config_tags"]),
config_type: config_format_for_item(&item),
md5: optional_string_field(&item, &["md5"]),
@@ -1623,12 +1853,13 @@ fn parse_config_detail(value: Value, data_id: String, group: String, namespace:
group: string_field(data, &["group", "groupName"]).if_empty(&group),
namespace: string_field(data, &["tenant", "namespaceId"]).if_empty(&namespace),
app_name: optional_string_field(data, &["appName", "app_name"]),
desc: optional_string_field(data, &["desc", "description"]),
desc: optional_string_field(data, &["desc", "description", "configDesc", "config_desc"]),
tags: optional_string_field(data, &["tags", "configTags", "config_tags"]),
config_type: config_format_for_item(data).or_else(|| infer_config_format(&data_id)),
md5: optional_string_field(data, &["md5"]),
encrypted_data_key: optional_string_field(data, &["encryptedDataKey"]),
content: optional_string_field(data, &["content"]).or_else(|| value.as_str().map(str::to_string)),
content: optional_string_field(data, &["content", "value", "configValue", "config_value"])
.or_else(|| value.as_str().map(str::to_string)),
}
}
@@ -2031,6 +2262,15 @@ mod tests {
socket.write_all(response.as_bytes()).await.unwrap();
}
async fn write_text_response(socket: &mut tokio::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
}
async fn write_json_response_with_captcha_token(socket: &mut tokio::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nCaptcha-Token: 1234567890abcdeffedcba0987654321\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
@@ -2112,6 +2352,95 @@ mod tests {
server.await.unwrap();
}
#[tokio::test]
async fn v3_console_namespace_creation_uses_custom_namespace_id() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().next(), Some("POST"));
assert_eq!(request.split_whitespace().nth(1), Some("/nacos/v3/console/core/namespace"));
assert!(request.contains("customNamespaceId=team-dev"));
write_json_response(&mut socket, r#"{"code":0,"message":"success","data":true}"#).await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.context_path = "/nacos".to_string();
config.version_mode = Some(NacosVersionMode::V3);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-dev".to_string()),
namespace_name: "Team Development".to_string(),
namespace_desc: Some("Development environment".to_string()),
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn v2_namespace_creation_uses_legacy_custom_namespace_id() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().next(), Some("POST"));
assert_eq!(request.split_whitespace().nth(1), Some("/nacos/v1/console/namespaces"));
assert!(request.contains("customNamespaceId=team-v2"));
write_json_response(&mut socket, "true").await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.context_path = "/nacos".to_string();
config.version_mode = Some(NacosVersionMode::V2);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-v2".to_string()),
namespace_name: "Team V2".to_string(),
namespace_desc: None,
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_namespace_creation_falls_back_to_nacos_compatible_v1_route() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/core/namespace", "/v3/console/core/namespace/create"] {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().nth(1), Some(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().nth(1), Some("/v1/console/namespaces"));
assert!(request.contains("customNamespaceId=team-rnacos"));
write_json_response(&mut socket, "true").await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-rnacos".to_string()),
namespace_name: "Team r-nacos".to_string(),
namespace_desc: None,
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn version_mode_auto_falls_back_from_v3_to_v1_config_paths() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2171,6 +2500,276 @@ mod tests {
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_detail_enriches_raw_openapi_content_with_console_metadata() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(request.contains("tenant=ops"));
assert!(request.contains("dataId=qilong-test"));
assert!(request.contains("group=qilong-test"));
assert!(request.to_ascii_lowercase().contains("token: console-token"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"value":"cloud_providers: {}\n","md5":"abc123","configType":"YAML","desc":"r-nacos description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin.rnacos_console_session.lock().await.token = Some(RNacosConsoleToken {
token: "console-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(300),
});
let detail = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap();
assert_eq!(detail.content.as_deref(), Some("cloud_providers: {}\n"));
assert_eq!(detail.config_type.as_deref(), Some("yaml"));
assert_eq!(detail.desc.as_deref(), Some("r-nacos description"));
assert_eq!(detail.md5.as_deref(), Some("abc123"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_metadata_supports_no_auth_console() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(!request.to_ascii_lowercase().contains("\ntoken:"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"anonymous console metadata"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
let admin = NacosOpenApiAdmin::new(config).unwrap();
let detail = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap();
assert_eq!(detail.config_type.as_deref(), Some("yaml"));
assert_eq!(detail.desc.as_deref(), Some("anonymous console metadata"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_metadata_propagates_captcha_requirement_for_ui_retry() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/rnacos/api/console/v2/config/info?"));
write_text_response(&mut socket, "<html><body>login required</body></html>").await;
let (mut socket, _) = listener.accept().await.unwrap();
assert_eq!(read_request_target(&mut socket).await, "/rnacos/api/console/v2/login/captcha");
write_json_response_with_captcha_token(&mut socket, r#"{"success":true,"data":"captcha-image"}"#).await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
let error = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap_err();
assert!(error.contains("NACOS_ERROR[rnacosConsoleCaptchaRequired]"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_list_enriches_type_and_description_from_console() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v3/console/cs/config/list?"));
write_not_found_response(&mut socket).await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_json_response(
&mut socket,
r#"{"totalCount":1,"pageItems":[{"dataId":"qilong-test","group":"qilong-test","tenant":"ops"}]}"#,
)
.await;
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"r-nacos description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin.rnacos_console_session.lock().await.token = Some(RNacosConsoleToken {
token: "console-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(300),
});
let list = admin
.list_configs(NacosConfigQuery {
namespace: Some("ops".to_string()),
group: None,
data_id: None,
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(20),
})
.await
.unwrap();
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].config_type.as_deref(), Some("yaml"));
assert_eq!(list.items[0].desc.as_deref(), Some("r-nacos description"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_list_enriches_description_from_no_auth_console_when_type_is_inferred() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v3/console/cs/config/list?"));
write_not_found_response(&mut socket).await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_json_response(
&mut socket,
r#"{"totalCount":1,"pageItems":[{"dataId":"application.yaml","group":"DEFAULT_GROUP","tenant":"ops"}]}"#,
)
.await;
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "server:\n port: 8848\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(!request.to_ascii_lowercase().contains("\ntoken:"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"anonymous list description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
let admin = NacosOpenApiAdmin::new(config).unwrap();
let list = admin
.list_configs(NacosConfigQuery {
namespace: Some("ops".to_string()),
group: None,
data_id: None,
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(20),
})
.await
.unwrap();
assert_eq!(list.items[0].config_type.as_deref(), Some("yaml"));
assert_eq!(list.items[0].desc.as_deref(), Some("anonymous list description"));
server.await.unwrap();
}
#[tokio::test]
async fn explicit_rnacos_lists_openapi_namespaces_without_console_url_when_health_is_unavailable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2711,6 +3310,31 @@ mod tests {
assert_eq!(parsed.content.as_deref(), Some("hello"));
}
#[test]
fn parses_rnacos_console_config_info_metadata() {
let parsed = parse_config_detail(
serde_json::json!({
"success": true,
"data": {
"value": "cloud_providers: {}\n",
"md5": "abc123",
"configType": "YAML",
"desc": "r-nacos description"
}
}),
"qilong-test".to_string(),
"qilong-test".to_string(),
"ops".to_string(),
);
assert_eq!(parsed.data_id, "qilong-test");
assert_eq!(parsed.group, "qilong-test");
assert_eq!(parsed.namespace, "ops");
assert_eq!(parsed.content.as_deref(), Some("cloud_providers: {}\n"));
assert_eq!(parsed.config_type.as_deref(), Some("yaml"));
assert_eq!(parsed.desc.as_deref(), Some("r-nacos description"));
assert_eq!(parsed.md5.as_deref(), Some("abc123"));
}
#[test]
fn builds_v3_publish_form_fields() {
let (v3_form, v1_form) = build_publish_forms(
@@ -3076,4 +3700,14 @@ mod tests {
assert_eq!(classify_nacos_error("404 Not Found"), "apiVersionMismatch");
assert_eq!(classify_nacos_error("connection refused"), "connectionFailed");
}
#[test]
fn treats_gateway_wrapped_missing_content_search_routes_as_unsupported() {
assert!(content_search_endpoint_is_unsupported(
r#"NACOS_ERROR[contextPathMismatch]: Nacos admin /v3/admin/cs/config/list returned 500 Internal Server Error: {"message":"No static resource v3/admin/cs/config/list."}"#
));
assert!(!content_search_endpoint_is_unsupported(
"NACOS_ERROR[requestFailed]: Nacos admin /v3/admin/cs/config/list returned 500 Internal Server Error: database unavailable"
));
}
}
+3
View File
@@ -6,9 +6,12 @@
//! still supports an older Rust toolchain. A future SDK adapter can implement
//! the same port without changing commands, routes, or frontend contracts.
pub mod archive;
pub mod batch;
pub mod config;
pub mod http;
pub mod port;
pub mod search;
pub mod service;
pub mod types;
+11
View File
@@ -9,6 +9,17 @@ pub trait NacosAdmin: Send + Sync {
async fn create_namespace(&self, req: NacosNamespaceCreate) -> Result<(), String>;
async fn update_namespace(&self, req: NacosNamespaceUpdate) -> Result<(), String>;
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String>;
/// Returns `Ok(None)` only when the server does not expose a native
/// content-search endpoint. Authentication, throttling and transport
/// failures are returned as errors so callers never amplify them with a
/// more expensive full scan.
async fn search_config_content_page(
&self,
namespace: &str,
query: &str,
page_no: u32,
page_size: u32,
) -> Result<Option<NacosConfigList>, String>;
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String>;
async fn publish_config(&self, req: NacosConfigUpsert) -> Result<(), String>;
async fn delete_config(&self, key: NacosConfigKey) -> Result<(), String>;
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -1,3 +1,5 @@
use std::future::Future;
use crate::connection::AppState;
use crate::models::connection::DatabaseType;
use crate::nacos::types::*;
@@ -48,6 +50,24 @@ pub async fn nacos_list_configs_core(
admin.list_configs(query).await
}
pub async fn nacos_search_config_content_core<F, Fut>(
state: &AppState,
conn_id: &str,
request: NacosContentSearchRequest,
on_progress: F,
) -> Result<NacosContentSearchResult, String>
where
F: Fn(NacosSearchProgress) -> Fut + Send + Sync,
Fut: Future<Output = ()> + Send,
{
let admin = get_admin(state, conn_id).await?;
crate::nacos::search::search_config_content(admin, request, on_progress).await
}
pub fn nacos_cancel_operation_core(operation_id: &str) -> bool {
crate::nacos::search::cancel_operation(operation_id)
}
pub async fn nacos_get_config_core(
state: &AppState,
conn_id: &str,
@@ -155,7 +175,7 @@ pub async fn nacos_raw_request_core(
admin.raw_request(req).await
}
async fn get_admin(
pub(crate) async fn get_admin(
state: &AppState,
conn_id: &str,
) -> Result<std::sync::Arc<dyn crate::nacos::port::NacosAdmin>, String> {
@@ -167,7 +187,7 @@ async fn get_admin(
state.nacos_registry.get_or_build_config(conn_id, admin_config).await
}
async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> {
pub(crate) async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
if cfg.read_only {
Err(format!("{action} is blocked because this connection is read-only"))
+166
View File
@@ -133,6 +133,172 @@ pub struct NacosConfigList {
pub items: Vec<NacosConfigItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosNamespaceScope {
#[default]
CurrentNamespace,
AllNamespaces,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentSearchRequest {
pub operation_id: String,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub scope: NacosNamespaceScope,
pub query: String,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub data_id: Option<String>,
#[serde(default)]
pub max_results: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentMatch {
pub namespace: String,
pub group: String,
pub data_id: String,
pub line_number: u64,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosSearchFailure {
pub namespace: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosSearchProgress {
pub operation_id: String,
pub phase: String,
#[serde(default)]
pub namespace: Option<String>,
pub scanned: u64,
#[serde(default)]
pub total: Option<u64>,
pub matched: u64,
#[serde(default)]
pub matches: Vec<NacosContentMatch>,
#[serde(default)]
pub failures: Vec<NacosSearchFailure>,
pub truncated: bool,
pub cancelled: bool,
pub done: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentSearchResult {
pub operation_id: String,
pub scanned: u64,
pub matches: Vec<NacosContentMatch>,
pub failures: Vec<NacosSearchFailure>,
pub truncated: bool,
pub cancelled: bool,
pub incomplete: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosConfigSelectionScope {
Selected,
Filtered,
#[default]
Namespace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigSelector {
pub namespace: String,
#[serde(default)]
pub scope: NacosConfigSelectionScope,
#[serde(default)]
pub keys: Vec<NacosConfigKey>,
#[serde(default)]
pub query: Option<NacosConfigQuery>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NacosConflictPolicy {
#[default]
Abort,
Skip,
Overwrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchPreviewItem {
pub namespace: String,
pub group: String,
pub data_id: String,
pub status: String,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchPreview {
pub plan_hash: String,
pub total: u64,
pub created: u64,
pub conflicts: u64,
pub invalid: u64,
pub items: Vec<NacosBatchPreviewItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchItemResult {
pub namespace: String,
pub group: String,
pub data_id: String,
pub status: String,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchReport {
pub operation_id: String,
#[serde(default)]
pub plan_hash: Option<String>,
pub total: u64,
pub created: u64,
pub overwritten: u64,
pub skipped: u64,
pub failed: u64,
pub aborted: bool,
pub partial: bool,
pub cancelled: bool,
pub items: Vec<NacosBatchItemResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigTransferRequest {
pub operation_id: String,
pub source_connection_id: String,
pub target_connection_id: String,
pub source: NacosConfigSelector,
pub target_namespace: String,
#[serde(default)]
pub conflict_policy: NacosConflictPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigUpsert {
+1 -1
View File
@@ -1800,7 +1800,7 @@ mod tests {
comment: None,
});
let diffs = diff_indexes(&[source_index.clone()], &[target_index]);
let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].diff_type, "modified");
assert_eq!(diffs[0].changes, vec![format!("columns: attr, attr2 → attr, attr2, {functional_key_part}")]);