feat(config): 支持完整配置导入导出

This commit is contained in:
上玄
2026-08-12 09:34:02 +08:00
parent 988ba63d40
commit 3fc04eeb4f
17 changed files with 1047 additions and 91 deletions
+12
View File
@@ -17,6 +17,12 @@ func (s *ProxyService) LoadUserConfig() (UserConfig, error) {
if s == nil {
return serverconfig.DefaultConfig(), nil
}
s.configMu.Lock()
defer s.configMu.Unlock()
return s.loadUserConfig()
}
func (s *ProxyService) loadUserConfig() (UserConfig, error) {
app := application.Get()
ctx := context.Background()
if app != nil {
@@ -36,6 +42,12 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
if s == nil {
return nil
}
s.configMu.Lock()
defer s.configMu.Unlock()
return s.saveUserConfig(cfg)
}
func (s *ProxyService) saveUserConfig(cfg UserConfig) error {
app := application.Get()
ctx := context.Background()
if app != nil {
+195
View File
@@ -0,0 +1,195 @@
package client
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
serverconfig "cursor/internal/backend/server/config"
"gopkg.in/yaml.v3"
)
const maxConfigTransferFileSize = 4 << 20
type importedUserConfigDocument struct {
serverconfig.Config `yaml:",inline"`
LegacyRouting any `yaml:"routing,omitempty"`
}
// ExportUserConfig 将当前完整配置导出为 YAML 文件。
func (s *ProxyService) ExportUserConfig(path string) (string, error) {
if s == nil {
return "", errors.New("配置服务未初始化")
}
targetPath := normalizeConfigExportPath(path)
if targetPath == "" {
return "", errors.New("导出路径不能为空")
}
s.configMu.Lock()
defer s.configMu.Unlock()
cfg, err := s.loadUserConfig()
if err != nil {
return "", fmt.Errorf("读取当前配置失败: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("序列化导出配置失败: %w", err)
}
if err := writeExportedUserConfig(targetPath, data); err != nil {
return "", err
}
return targetPath, nil
}
// ImportUserConfig 从 YAML 文件校验并替换当前完整配置。
func (s *ProxyService) ImportUserConfig(path string) (UserConfig, error) {
if s == nil {
return serverconfig.Config{}, errors.New("配置服务未初始化")
}
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.configMu.Lock()
defer s.configMu.Unlock()
if err := ensureConfigImportAllowed(s.GetState()); err != nil {
return serverconfig.Config{}, err
}
data, err := readImportedUserConfig(path)
if err != nil {
return serverconfig.Config{}, err
}
cfg, err := decodeImportedUserConfig(data)
if err != nil {
return serverconfig.Config{}, err
}
if err := s.saveUserConfig(cfg); err != nil {
return serverconfig.Config{}, fmt.Errorf("保存导入配置失败: %w", err)
}
persisted, err := s.loadUserConfig()
if err != nil {
return serverconfig.Config{}, fmt.Errorf("重新读取导入配置失败: %w", err)
}
return persisted, nil
}
func normalizeConfigExportPath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return ""
}
extension := strings.ToLower(filepath.Ext(trimmed))
if extension != ".yaml" && extension != ".yml" {
trimmed += ".yaml"
}
return filepath.Clean(trimmed)
}
func writeExportedUserConfig(path string, data []byte) error {
directory := filepath.Dir(path)
file, err := os.CreateTemp(directory, ".cursor-byok-config-*.tmp")
if err != nil {
return fmt.Errorf("创建导出配置临时文件失败: %w", err)
}
temporaryPath := file.Name()
defer os.Remove(temporaryPath)
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return fmt.Errorf("设置导出配置权限失败: %w", err)
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return fmt.Errorf("写入导出配置失败: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("同步导出配置失败: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭导出配置失败: %w", err)
}
if err := replaceExportFile(temporaryPath, path); err != nil {
return fmt.Errorf("替换导出配置失败: %w", err)
}
return nil
}
func ensureConfigImportAllowed(state ProxyState) error {
if state.BackendRunning || state.ProxyRunning || state.Running {
return errors.New("服务运行中不能导入完整配置,请先停止服务")
}
return nil
}
func readImportedUserConfig(path string) ([]byte, error) {
sourcePath := strings.TrimSpace(path)
if sourcePath == "" {
return nil, errors.New("导入路径不能为空")
}
file, err := os.Open(sourcePath)
if err != nil {
return nil, fmt.Errorf("打开导入配置失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("读取导入配置信息失败: %w", err)
}
if !info.Mode().IsRegular() {
return nil, errors.New("导入配置必须是普通文件")
}
if info.Size() > maxConfigTransferFileSize {
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
}
data, err := io.ReadAll(io.LimitReader(file, maxConfigTransferFileSize+1))
if err != nil {
return nil, fmt.Errorf("读取导入配置失败: %w", err)
}
if len(data) > maxConfigTransferFileSize {
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
}
return data, nil
}
func decodeImportedUserConfig(data []byte) (serverconfig.Config, error) {
if err := validateImportedUserConfigDocument(data); err != nil {
return serverconfig.Config{}, err
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
var document importedUserConfigDocument
if err := decoder.Decode(&document); err != nil {
return serverconfig.Config{}, fmt.Errorf("导入配置包含未知字段或无效 YAML: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return serverconfig.Config{}, errors.New("导入配置只能包含单个 YAML 文档")
} else if !errors.Is(err, io.EOF) {
return serverconfig.Config{}, fmt.Errorf("解析导入配置尾部失败: %w", err)
}
normalized, err := serverconfig.NormalizeConfig(document.Config)
if err != nil {
return serverconfig.Config{}, fmt.Errorf("导入配置校验失败: %w", err)
}
return normalized, nil
}
func validateImportedUserConfigDocument(data []byte) error {
decoder := yaml.NewDecoder(bytes.NewReader(data))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
if errors.Is(err, io.EOF) {
return errors.New("导入配置不能为空")
}
return fmt.Errorf("导入配置不是有效 YAML: %w", err)
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return errors.New("导入配置顶层必须是 YAML 对象")
}
return nil
}
@@ -0,0 +1,9 @@
//go:build !windows
package client
import "os"
func replaceExportFile(sourcePath, targetPath string) error {
return os.Rename(sourcePath, targetPath)
}
@@ -0,0 +1,21 @@
//go:build windows
package client
import "golang.org/x/sys/windows"
func replaceExportFile(sourcePath, targetPath string) error {
source, err := windows.UTF16PtrFromString(sourcePath)
if err != nil {
return err
}
target, err := windows.UTF16PtrFromString(targetPath)
if err != nil {
return err
}
return windows.MoveFileEx(
source,
target,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
}
+245
View File
@@ -0,0 +1,245 @@
package client
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
serverconfig "cursor/internal/backend/server/config"
"gopkg.in/yaml.v3"
)
func TestExportAndImportUserConfigRoundTrip(t *testing.T) {
source := newConfigTransferTestService(t)
want := serverconfig.DefaultConfig()
want.Log = true
want.ModelAdapters = []serverconfig.ModelAdapterConfig{{
DisplayName: "迁移模型",
Type: "openai",
BaseURL: "https://provider.example/v1",
APIKey: "migration-secret",
TooltipData: "迁移备注",
ModelID: "model-a",
ReasoningEffort: "medium",
OpenAIEndpoint: "/v1/responses",
}}
if err := source.SaveUserConfig(want); err != nil {
t.Fatalf("SaveUserConfig() error = %v", err)
}
exportPath, err := source.ExportUserConfig(filepath.Join(t.TempDir(), "cursor-byok-backup"))
if err != nil {
t.Fatalf("ExportUserConfig() error = %v", err)
}
if filepath.Ext(exportPath) != ".yaml" {
t.Fatalf("ExportUserConfig() path = %q, want .yaml extension", exportPath)
}
if runtime.GOOS != "windows" {
info, statErr := os.Stat(exportPath)
if statErr != nil {
t.Fatalf("Stat() error = %v", statErr)
}
if gotMode := info.Mode().Perm(); gotMode != 0o600 {
t.Fatalf("export mode = %o, want 600", gotMode)
}
}
target := newConfigTransferTestService(t)
got, err := target.ImportUserConfig(exportPath)
if err != nil {
t.Fatalf("ImportUserConfig() error = %v", err)
}
if !got.Log || len(got.ModelAdapters) != 1 {
t.Fatalf("ImportUserConfig() = %#v", got)
}
adapter := got.ModelAdapters[0]
if adapter.DisplayName != "迁移模型" || adapter.APIKey != "migration-secret" || adapter.ModelID != "model-a" {
t.Fatalf("imported adapter = %#v", adapter)
}
persisted, err := target.LoadUserConfig()
if err != nil {
t.Fatalf("LoadUserConfig() error = %v", err)
}
if len(persisted.ModelAdapters) != 1 || persisted.ModelAdapters[0].APIKey != "migration-secret" {
t.Fatalf("persisted config = %#v", persisted)
}
}
func TestWriteExportedUserConfigReplacesExistingFile(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "config.yaml")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := writeExportedUserConfig(path, []byte("new")); err != nil {
t.Fatalf("writeExportedUserConfig() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new" {
t.Fatalf("exported content = %q, want new", data)
}
matches, err := filepath.Glob(filepath.Join(directory, ".cursor-byok-config-*.tmp"))
if err != nil || len(matches) != 0 {
t.Fatalf("temporary exports = %v, error = %v", matches, err)
}
}
func TestEnsureConfigImportAllowedRejectsRunningService(t *testing.T) {
for _, state := range []ProxyState{
{BackendRunning: true},
{ProxyRunning: true},
{Running: true},
} {
if err := ensureConfigImportAllowed(state); err == nil {
t.Fatalf("ensureConfigImportAllowed(%+v) error = nil", state)
}
}
if err := ensureConfigImportAllowed(ProxyState{}); err != nil {
t.Fatalf("ensureConfigImportAllowed(stopped) error = %v", err)
}
}
func TestImportUserConfigWaitsForLifecycleTransition(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("modelAdapters: []\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
service.lifecycleMu.Lock()
done := make(chan error, 1)
go func() {
_, err := service.ImportUserConfig(path)
done <- err
}()
select {
case err := <-done:
service.lifecycleMu.Unlock()
t.Fatalf("ImportUserConfig() completed during lifecycle transition: %v", err)
case <-time.After(100 * time.Millisecond):
}
service.lifecycleMu.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("ImportUserConfig() error = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("ImportUserConfig() did not resume after lifecycle transition")
}
}
func TestDecodeImportedUserConfigRejectsNonMappingDocuments(t *testing.T) {
for _, raw := range []string{"", "null\n", "[]\n", "value\n"} {
if _, err := decodeImportedUserConfig([]byte(raw)); err == nil {
t.Fatalf("decodeImportedUserConfig(%q) error = nil", raw)
}
}
}
func TestDecodeImportedUserConfigAcceptsEmptyReasoningEffort(t *testing.T) {
raw := []byte("modelAdapters:\n - displayName: model\n type: openai\n baseURL: https://example.com/v1\n apiKey: secret\n tooltipData: migrated model\n modelID: model-a\n reasoningEffort: ''\n openAIEndpoint: /v1/responses\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if got.ModelAdapters[0].ReasoningEffort != "" {
t.Fatalf("reasoningEffort = %q, want empty", got.ModelAdapters[0].ReasoningEffort)
}
}
func TestImportUserConfigRejectsUnknownFieldsWithoutOverwriting(t *testing.T) {
service := newConfigTransferTestService(t)
current := serverconfig.DefaultConfig()
current.Log = true
if err := service.SaveUserConfig(current); err != nil {
t.Fatalf("SaveUserConfig() error = %v", err)
}
path := filepath.Join(t.TempDir(), "unknown.yaml")
if err := os.WriteFile(path, []byte("modelAdapters: []\nunknownSetting: true\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "未知字段") {
t.Fatalf("ImportUserConfig() error = %v, want unknown field error", err)
}
persisted, err := service.LoadUserConfig()
if err != nil {
t.Fatalf("LoadUserConfig() error = %v", err)
}
if !persisted.Log {
t.Fatal("invalid import overwrote the existing config")
}
}
func TestImportUserConfigRejectsMultipleDocuments(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "multiple.yaml")
content := "modelAdapters: []\n---\nmodelAdapters: []\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "单个 YAML 文档") {
t.Fatalf("ImportUserConfig() error = %v, want multiple document error", err)
}
}
func TestImportUserConfigRejectsOversizedFile(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "oversized.yaml")
content := make([]byte, maxConfigTransferFileSize+1)
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "不能超过") {
t.Fatalf("ImportUserConfig() error = %v, want size limit error", err)
}
}
func TestDecodeImportedUserConfigNormalizesValues(t *testing.T) {
raw := []byte("backendListenAddr: ' 127.0.0.1:12345 '\nproxyListenAddr: '127.0.0.1:12346'\nmodelAdapters: []\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if got.BackendListenAddr != "127.0.0.1:12345" || got.ProxyListenAddr != "127.0.0.1:12346" {
t.Fatalf("decodeImportedUserConfig() = %#v", got)
}
encoded, err := yaml.Marshal(got)
if err != nil {
t.Fatalf("yaml.Marshal() error = %v", err)
}
if !strings.Contains(string(encoded), "backendListenAddr: 127.0.0.1:12345") {
t.Fatalf("encoded config = %s", encoded)
}
}
func TestDecodeImportedUserConfigAcceptsLegacyRouting(t *testing.T) {
raw := []byte("modelAdapters: []\nrouting:\n strategy: legacy\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if len(got.ModelAdapters) != 0 {
t.Fatalf("decodeImportedUserConfig() = %#v", got)
}
}
func newConfigTransferTestService(t *testing.T) *ProxyService {
t.Helper()
root := t.TempDir()
return &ProxyService{
store: serverconfig.NewStore(filepath.Join(root, "config.yaml"), filepath.Join(root, "logs")),
}
}
+4
View File
@@ -54,6 +54,8 @@ type ProxyState struct {
// StartProxy 用于处理与 StartProxy 相关的逻辑。
func (s *ProxyService) StartProxy() (ProxyState, error) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
logger.Infof("start service requested config_path=%s logs_root=%s", s.configPath, s.logsRoot)
fail := func(step string, err error) (ProxyState, error) {
logger.Errorf("start service failed step=%s err=%v", step, err)
@@ -127,6 +129,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
// StopProxy 用于处理与 StopProxy 相关的逻辑。
func (s *ProxyService) StopProxy() (ProxyState, error) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
logger.Infof("stop service requested")
fail := func(step string, err error) (ProxyState, error) {
logger.Errorf("stop service failed step=%s err=%v", step, err)
+2
View File
@@ -49,6 +49,8 @@ type ProxyService struct {
// configMu 表示当前声明中的 configMu。
configMu sync.Mutex
// lifecycleMu 串行化服务启停与完整配置导入,避免过渡状态下切换配置。
lifecycleMu sync.Mutex
// configPath 表示当前声明中的 configPath。
configPath string
// store 表示统一的配置存储。