fix(core-config): preserve user YAML customizations

Treat an existing core config as the startup source of truth instead of rebuilding it from the example template. Preserve arbitrary user-defined top-level fields, nested mappings, lists, provider settings, comments, and WebUI-installed plugin metadata while applying only GUI-managed settings. Support correctly indented insertion of deeply nested YAML mappings during upgrade migrations and add broad regression coverage.
This commit is contained in:
lzt404 committed 2026-08-10 17:05:27 +08:00
1 parent 672780878d
commit 9d7b645c1d
3 files changed
+205 -49

No files matched your search

+5 -2
View File
@@ -178,8 +178,11 @@ pub(crate) fn merge_core_config_yaml(
current: Option<&str>,
config: &GuiConfigFile,
) -> Result<String, String> {
let merged = merge_core_config_fields(template, current)?;
apply_gui_managed_settings(&merged, config)
let base = match current {
Some(current) => current.to_string(),
None => merge_core_config_fields(template, None)?,
};
apply_gui_managed_settings(&base, config)
}
pub(crate) fn merge_core_config_fields(
+150 -28
View File
@@ -243,34 +243,47 @@ pub(crate) fn render_yaml_value_changes(
remaining_changes.push(change);
}
}
let mut missing_nested_groups: Vec<(String, Vec<(String, serde_norway::Value)>)> = Vec::new();
for change in &remaining_changes {
if change.path.len() != 2
let mut missing_nested_groups: Vec<(Vec<String>, Vec<(String, serde_norway::Value)>)> =
Vec::new();
let mut yaml_edit_changes = Vec::new();
for change in remaining_changes {
if change.path.len() < 2
|| yaml_value_at_path(original, &change.path).is_some()
|| yaml_value_at_path(original, &change.path[..1])
|| yaml_value_at_path(original, &change.path[..change.path.len() - 1])
.and_then(serde_norway::Value::as_mapping)
.is_none()
{
yaml_edit_changes.push(change);
continue;
}
let section = change.path[0].clone();
let entry = (change.path[1].clone(), change.value.clone());
let parent_path = change.path[..change.path.len() - 1].to_vec();
let entry = (
change.path.last().cloned().unwrap_or_default(),
change.value,
);
if let Some((_, entries)) = missing_nested_groups
.iter_mut()
.find(|(existing, _)| existing == &section)
.find(|(existing, _)| existing == &parent_path)
{
entries.push(entry);
} else {
missing_nested_groups.push((section, vec![entry]));
missing_nested_groups.push((parent_path, vec![entry]));
}
}
for (section, entries) in missing_nested_groups {
for (parent_path, entries) in missing_nested_groups {
if let Some(updated_content) =
insert_yaml_block_mapping_values(&editable_content, &section, &entries)?
insert_yaml_block_mapping_values_at_path(&editable_content, &parent_path, &entries)?
{
editable_content = updated_content;
} else {
for (key, value) in entries {
let mut path = parent_path.clone();
path.push(key);
yaml_edit_changes.push(YamlValueChange { path, value });
}
}
}
remaining_changes = yaml_edit_changes;
let file = editable_content
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
@@ -287,11 +300,77 @@ pub(crate) fn render_yaml_value_changes(
let validated = serde_norway::from_str::<serde_norway::Value>(&rendered)
.map_err(|err| format!("验证更新后的内核配置失败: {err}"))?;
if validated != *updated {
return Err("更新后的内核配置与预期值不一致,已拒绝写入".to_string());
let path = first_yaml_mismatch_path(updated, &validated, &mut Vec::new())
.unwrap_or_else(|| "<unknown>".to_string());
return Err(format!(
"更新后的内核配置与预期值不一致(路径: {path}),已拒绝写入"
));
}
Ok(rendered)
}
pub(crate) fn first_yaml_mismatch_path(
expected: &serde_norway::Value,
actual: &serde_norway::Value,
path: &mut Vec<String>,
) -> Option<String> {
if expected == actual {
return None;
}
match (expected, actual) {
(
serde_norway::Value::Mapping(expected_mapping),
serde_norway::Value::Mapping(actual_mapping),
) => {
for (key, expected_value) in expected_mapping {
let key_name = key
.as_str()
.map(str::to_string)
.unwrap_or_else(|| "<non-string-key>".to_string());
path.push(key_name);
let mismatch = actual_mapping
.get(key)
.and_then(|actual_value| {
first_yaml_mismatch_path(expected_value, actual_value, path)
})
.or_else(|| (!actual_mapping.contains_key(key)).then(|| path.join(".")));
path.pop();
if mismatch.is_some() {
return mismatch;
}
}
for key in actual_mapping.keys() {
if !expected_mapping.contains_key(key) {
let key = key.as_str().unwrap_or("<non-string-key>");
return Some(if path.is_empty() {
key.to_string()
} else {
format!("{}.{}", path.join("."), key)
});
}
}
Some(path.join("."))
}
(
serde_norway::Value::Sequence(expected_values),
serde_norway::Value::Sequence(actual_values),
) => {
for (index, (expected_value, actual_value)) in
expected_values.iter().zip(actual_values).enumerate()
{
path.push(format!("[{index}]"));
let mismatch = first_yaml_mismatch_path(expected_value, actual_value, path);
path.pop();
if mismatch.is_some() {
return mismatch;
}
}
Some(format!("{}[length]", path.join(".")))
}
_ => Some(path.join(".")),
}
}
pub(crate) fn normalize_nested_yaml_comment_indentation(content: &str) -> String {
let lines = content.split_inclusive('\n').collect::<Vec<_>>();
let mut normalized = String::with_capacity(content.len());
@@ -394,36 +473,48 @@ pub(crate) fn yaml_value_at_path<'a>(
})
}
pub(crate) fn insert_yaml_block_mapping_values(
pub(crate) fn insert_yaml_block_mapping_values_at_path(
content: &str,
section: &str,
parent_path: &[String],
entries: &[(String, serde_norway::Value)],
) -> Result<Option<String>, String> {
let mut offset = 0;
let mut section_end = None;
let mut parent_end = None;
let mut parent_indent = 0;
let mut child_indent = None;
let mut mapping_stack: Vec<(usize, String)> = Vec::new();
for line in content.split_inclusive('\n') {
let body = line.trim_end_matches(['\r', '\n']);
let trimmed = body.trim_start_matches([' ', '\t']);
let indent = body.len().saturating_sub(trimmed.len());
if section_end.is_none() {
let section_header = trimmed
.split_once('#')
.map(|(value, _)| value)
.unwrap_or(trimmed)
.trim_end();
if indent == 0 && section_header == format!("{section}:") {
section_end = Some(offset + line.len());
if parent_end.is_none() {
if let Some(key) = yaml_block_mapping_key(trimmed) {
while mapping_stack
.last()
.is_some_and(|(ancestor_indent, _)| *ancestor_indent >= indent)
{
mapping_stack.pop();
}
mapping_stack.push((indent, key));
if mapping_stack.len() == parent_path.len()
&& mapping_stack
.iter()
.zip(parent_path)
.all(|((_, actual), expected)| actual == expected)
{
parent_end = Some(offset + line.len());
parent_indent = indent;
}
}
} else if !trimmed.is_empty() && !trimmed.starts_with('#') {
if indent == 0 {
if indent <= parent_indent {
break;
}
child_indent.get_or_insert(indent);
}
offset += line.len();
}
let Some(section_end) = section_end else {
let Some(parent_end) = parent_end else {
return Ok(None);
};
let newline = if content.contains("\r\n") {
@@ -431,24 +522,55 @@ pub(crate) fn insert_yaml_block_mapping_values(
} else {
"\n"
};
let indent = " ".repeat(child_indent.unwrap_or(2));
let indent = " ".repeat(child_indent.unwrap_or(parent_indent + 2));
let mut insertion = String::new();
for (key, value) in entries {
let value = serde_json::to_string(value)
.map_err(|error| format!("序列化内核配置值失败: {error}"))?;
insertion.push_str(&indent);
insertion.push_str(key);
insertion.push_str(&render_yaml_mapping_key(key));
insertion.push_str(": ");
insertion.push_str(&value);
insertion.push_str(newline);
}
let mut updated = String::with_capacity(content.len() + insertion.len());
updated.push_str(&content[..section_end]);
updated.push_str(&content[..parent_end]);
updated.push_str(&insertion);
updated.push_str(&content[section_end..]);
updated.push_str(&content[parent_end..]);
Ok(Some(updated))
}
pub(crate) fn yaml_block_mapping_key(line: &str) -> Option<String> {
let header = line
.split_once('#')
.map(|(value, _)| value)
.unwrap_or(line)
.trim_end();
let key = header.strip_suffix(':')?.trim_end();
if key.is_empty() {
return None;
}
if key.starts_with('"') && key.ends_with('"') {
return serde_json::from_str(key).ok();
}
if key.starts_with('\'') && key.ends_with('\'') {
return Some(key[1..key.len() - 1].replace("''", "'"));
}
Some(key.to_string())
}
pub(crate) fn render_yaml_mapping_key(key: &str) -> String {
if !key.is_empty()
&& key
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
{
key.to_string()
} else {
serde_json::to_string(key).unwrap_or_else(|_| key.to_string())
}
}
pub(crate) fn replace_yaml_sequence_value(
content: &str,
path: &[String],
+50 -19
View File
@@ -708,9 +708,9 @@ fn unchanged_yaml_is_not_written_again() {
}
#[test]
fn startup_merge_uses_template_and_preserves_current_values() {
fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n";
let current = "host: 127.0.0.1\nport: 9000\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\n";
let current = "# User-edited configuration\nhost: 127.0.0.1\nport: 9000\n# User-owned nested values\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\ncustom-provider:\n base-url: https://example.com/v1\n headers:\n X-Custom-Header: custom-value\n models:\n - name: custom-model\n aliases: [custom-a, custom-b]\nplugins:\n custom-runtime-options:\n sandbox: strict\n environment:\n CUSTOM_FLAG: enabled\nrouting:\n custom-rules:\n - match: custom-*\n target: custom-provider\nremote-management:\n custom-dashboard-option: retained\n";
let config = GuiConfigFile {
locale: "zh-CN".to_string(),
port: 9527,
@@ -739,11 +739,12 @@ fn startup_merge_uses_template_and_preserves_current_values() {
routing_session_affinity_ttl: "1h".to_string(),
};
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
let current_document = serde_norway::from_str::<serde_norway::Value>(current).unwrap();
assert!(merged.contains("# Current release template"));
assert!(merged.contains("# template bind address"), "{merged}");
assert!(merged.contains("# New release option"));
assert!(merged.contains("# Nested template comment"));
assert!(merged.contains("# User-edited configuration"));
assert!(merged.contains("# User-owned nested values"));
assert!(!merged.contains("# Current release template"));
assert!(!merged.contains("# New release option"));
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
assert_eq!(
@@ -759,27 +760,57 @@ fn startup_merge_uses_template_and_preserves_current_values() {
assert_eq!(document["routing"]["session-affinity"], true);
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
assert_eq!(document["usage-statistics-enabled"], false);
assert_eq!(document["new-option"], serde_norway::Value::Bool(true));
assert!(document.get("new-option").is_none());
assert_eq!(document["nested"], current_document["nested"]);
assert!(document["nested"].get("added").is_none());
for key in ["list", "extra", "custom-provider"] {
assert_eq!(document[key], current_document[key], "user field {key}");
}
assert_eq!(
document["nested"]["keep"],
serde_norway::Value::String("current".to_string())
document["plugins"]["custom-runtime-options"],
current_document["plugins"]["custom-runtime-options"]
);
assert_eq!(
document["nested"]["added"],
serde_norway::Value::String("from-template".to_string())
document["routing"]["custom-rules"],
current_document["routing"]["custom-rules"]
);
assert_eq!(
document["nested"]["current-only"],
serde_norway::Value::String("retained".to_string())
document["remote-management"]["custom-dashboard-option"],
current_document["remote-management"]["custom-dashboard-option"]
);
assert_eq!(document["extra"], serde_norway::Value::Bool(true));
}
#[test]
fn startup_merge_preserves_plugin_store_config_written_by_core() {
let template = "host: \"\"\nport: 8317\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - template-key\nremote-management:\n secret-key: \"\"\nusage-statistics-enabled: true\nplugins:\n enabled: false\n dir: plugins\n configs:\n example:\n enabled: true\n priority: 1\nrouting:\n strategy: round-robin\n session-affinity: false\n session-affinity-ttl: \"\"\nproxy-url: \"\"\ncommercial-mode: false\n";
let current = "host: 127.0.0.1\nport: 8317\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - '123456'\nremote-management:\n secret-key: \"\"\nusage-statistics-enabled: true\nplugins:\n enabled: true\n dir: plugins\n configs:\n model-fallback-router:\n enabled: true\n store:\n id: model-fallback-router\n name: Model Fallback Router\n description: Retries matching model requests through configured fallback model names when the primary model fails with quota, rate-limit, transport, or configured HTTP status errors.\n author: thebtf\n version: 0.2.0\n release-tag: v0.2.0\n repository: https://github.com/thebtf/cpa-model-fallback-router\n tags:\n - Router\n - Model Router\n - Fallback\n install:\n type: github-release\nrouting:\n strategy: round-robin\n session-affinity: false\n session-affinity-ttl: \"\"\nproxy-url: \"\"\n";
let config = GuiConfigFile {
host: "0.0.0.0".to_string(),
allow_lan: true,
plugins_enabled: true,
..GuiConfigFile::default()
};
let merged = merge_core_config_yaml(template, Some(current), &config)
.unwrap_or_else(|error| panic!("plugin config merge failed: {error}"));
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
let current_document = serde_norway::from_str::<serde_norway::Value>(current).unwrap();
assert_eq!(document["host"], "0.0.0.0");
assert_eq!(
document["list"],
serde_norway::Value::Sequence(vec![
serde_norway::Value::String("current-a".to_string()),
serde_norway::Value::String("current-b".to_string()),
])
document["plugins"]["configs"]["model-fallback-router"],
current_document["plugins"]["configs"]["model-fallback-router"]
);
assert_eq!(
document["plugins"]["configs"]["model-fallback-router"]["store"]["version"],
"0.2.0"
);
assert_eq!(
document["plugins"]["configs"]["model-fallback-router"]["store"]["install"]["type"],
"github-release"
);
assert!(document.get("commercial-mode").is_none());
assert!(document["plugins"]["configs"].get("example").is_none());
}
#[test]