feat(nacos): enhance configuration management
This commit is contained in:
52 files changed
+7139
-263
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}")]);
|
||||
|
||||
Reference in new issue
Block a user