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:
1 parent
672780878d
commit
9d7b645c1d
3 files changed
+205
-49
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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 == §ion)
|
||||
.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, §ion, &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],
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user