From 3bf5781a06f56ef28c90c96a368cf377f5a302d4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:57:58 -0500 Subject: [PATCH] refactor(server): select the token store backend through a registry Postgres, object and git were wired through parallel if-else chains in main, so adding a backend meant editing four places. A single ordered registry keeps the selection priority and leaves main with one call site. Signed-off-by: Yordis Prieto --- cmd/server/backend.go | 238 +++++++++++++++++++++++++++++++++++++ cmd/server/backend_test.go | 107 +++++++++++++++++ cmd/server/main.go | 233 ++---------------------------------- 3 files changed, 353 insertions(+), 225 deletions(-) create mode 100644 cmd/server/backend.go create mode 100644 cmd/server/backend_test.go diff --git a/cmd/server/backend.go b/cmd/server/backend.go new file mode 100644 index 00000000000..52ffe14a15b --- /dev/null +++ b/cmd/server/backend.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v8/internal/store" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +// backendEnv carries what a backend needs to read its settings and place its spool. +type backendEnv struct { + // lookup returns the first non-empty value among the given environment keys. + lookup func(keys ...string) (string, bool) + // workDir is the process working directory. + workDir string + // writableBase is the preferred base for local spools, empty when none is known. + writableBase string +} + +// spoolBase returns the configured local path, falling back to the writable base and then the working directory. +func (e backendEnv) spoolBase(configured string) string { + if configured != "" { + return configured + } + if e.writableBase != "" { + return e.writableBase + } + return e.workDir +} + +// openedBackend is a started remote-backed store together with the paths the server runs from. +type openedBackend struct { + tokenStore coreauth.Store + configPath string + // authDir overrides the configured auth directory once the config is loaded. + authDir string + // ready logs that the backend is serving, called only after the config loaded. + ready func() +} + +// openBackendFunc starts a remote-backed store. +type openBackendFunc func() (*openedBackend, error) + +// storeBackend describes one remote token store. detect returns nil when the +// environment does not select the backend, otherwise a function that starts it. +type storeBackend struct { + name string + detect func(env backendEnv) openBackendFunc +} + +// storeBackends lists the backends in selection priority; the first one whose +// settings are present wins. Adding a backend means adding one entry. +var storeBackends = []storeBackend{ + {name: "postgres", detect: detectPostgresBackend}, + {name: "object", detect: detectObjectBackend}, + {name: "git", detect: detectGitBackend}, +} + +// selectBackend returns the opener of the first backend selected by the environment, or nil. +func selectBackend(env backendEnv) openBackendFunc { + for _, backend := range storeBackends { + if open := backend.detect(env); open != nil { + return open + } + } + return nil +} + +func detectPostgresBackend(env backendEnv) openBackendFunc { + dsn, ok := env.lookup("PGSTORE_DSN", "pgstore_dsn") + if !ok { + return nil + } + schema, _ := env.lookup("PGSTORE_SCHEMA", "pgstore_schema") + localPath, _ := env.lookup("PGSTORE_LOCAL_PATH", "pgstore_local_path") + spool := filepath.Join(env.spoolBase(localPath), "pgstore") + return func() (*openedBackend, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + inst, err := store.NewPostgresStore(ctx, store.PostgresStoreConfig{ + DSN: dsn, + Schema: schema, + SpoolDir: spool, + }) + cancel() + if err != nil { + return nil, fmt.Errorf("failed to initialize postgres token store: %w", err) + } + examplePath := filepath.Join(env.workDir, "config.example.yaml") + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if errBootstrap := inst.Bootstrap(ctx, examplePath); errBootstrap != nil { + return nil, fmt.Errorf("failed to bootstrap postgres-backed config: %w", errBootstrap) + } + return &openedBackend{ + tokenStore: inst, + configPath: inst.ConfigPath(), + authDir: inst.AuthDir(), + ready: func() { + log.Infof("postgres-backed token store enabled, workspace path: %s", inst.WorkDir()) + }, + }, nil + } +} + +func detectObjectBackend(env backendEnv) openBackendFunc { + endpoint, ok := env.lookup("OBJECTSTORE_ENDPOINT", "objectstore_endpoint") + if !ok { + return nil + } + access, _ := env.lookup("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key") + secret, _ := env.lookup("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key") + bucket, _ := env.lookup("OBJECTSTORE_BUCKET", "objectstore_bucket") + localPath, _ := env.lookup("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path") + return func() (*openedBackend, error) { + root := filepath.Join(env.spoolBase(localPath), "objectstore") + resolvedEndpoint := strings.TrimSpace(endpoint) + useSSL := true + if strings.Contains(resolvedEndpoint, "://") { + parsed, errParse := url.Parse(resolvedEndpoint) + if errParse != nil { + return nil, fmt.Errorf("failed to parse object store endpoint %q: %w", endpoint, errParse) + } + switch strings.ToLower(parsed.Scheme) { + case "http": + useSSL = false + case "https": + useSSL = true + default: + return nil, fmt.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme) + } + if parsed.Host == "" { + return nil, fmt.Errorf("object store endpoint %q is missing host information", endpoint) + } + resolvedEndpoint = parsed.Host + if parsed.Path != "" && parsed.Path != "/" { + resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/") + } + } + resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/") + inst, err := store.NewObjectTokenStore(store.ObjectStoreConfig{ + Endpoint: resolvedEndpoint, + Bucket: bucket, + AccessKey: access, + SecretKey: secret, + LocalRoot: root, + UseSSL: useSSL, + PathStyle: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize object token store: %w", err) + } + examplePath := filepath.Join(env.workDir, "config.example.yaml") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if errBootstrap := inst.Bootstrap(ctx, examplePath); errBootstrap != nil { + return nil, fmt.Errorf("failed to bootstrap object-backed config: %w", errBootstrap) + } + return &openedBackend{ + tokenStore: inst, + configPath: inst.ConfigPath(), + authDir: inst.AuthDir(), + ready: func() { + log.Infof("object-backed token store enabled, bucket: %s", bucket) + }, + }, nil + } +} + +func detectGitBackend(env backendEnv) openBackendFunc { + remoteURL, ok := env.lookup("GITSTORE_GIT_URL", "gitstore_git_url") + if !ok { + return nil + } + user, _ := env.lookup("GITSTORE_GIT_USERNAME", "gitstore_git_username") + password, _ := env.lookup("GITSTORE_GIT_TOKEN", "gitstore_git_token") + localPath, _ := env.lookup("GITSTORE_LOCAL_PATH", "gitstore_local_path") + branch, _ := env.lookup("GITSTORE_GIT_BRANCH", "gitstore_git_branch") + return func() (*openedBackend, error) { + root := filepath.Join(env.spoolBase(localPath), "gitstore") + inst := store.NewGitTokenStore(remoteURL, user, password, branch) + inst.SetBaseDir(filepath.Join(root, "auths")) + if errRepo := inst.EnsureRepository(); errRepo != nil { + return nil, fmt.Errorf("failed to prepare git token store: %w", errRepo) + } + configPath := inst.ConfigPath() + if configPath == "" { + configPath = filepath.Join(root, "config", "config.yaml") + } + if _, statErr := os.Stat(configPath); errors.Is(statErr, fs.ErrNotExist) { + examplePath := filepath.Join(env.workDir, "config.example.yaml") + if _, errExample := os.Stat(examplePath); errExample != nil { + return nil, fmt.Errorf("failed to find template config file: %w", errExample) + } + if errCopy := misc.CopyConfigTemplate(examplePath, configPath); errCopy != nil { + return nil, fmt.Errorf("failed to bootstrap git-backed config: %w", errCopy) + } + if errCommit := persistSeededConfig(context.Background(), configPath, inst.PersistConfig); errCommit != nil { + return nil, fmt.Errorf("failed to commit initial git-backed config: %w", errCommit) + } + log.Infof("git-backed config initialized from template: %s", configPath) + } else if statErr != nil { + return nil, fmt.Errorf("failed to inspect git-backed config: %w", statErr) + } + return &openedBackend{ + tokenStore: inst, + configPath: configPath, + authDir: inst.AuthDir(), + ready: func() { + log.Infof("git-backed token store enabled, repository path: %s", root) + }, + }, nil + } +} + +// loadBackendConfig loads the config a backend placed on disk and points it at the backend's auth directory. +func loadBackendConfig(opened *openedBackend, isCloudDeploy bool) (*config.Config, error) { + cfg, err := config.LoadConfigOptional(opened.configPath, isCloudDeploy) + if err != nil { + return cfg, err + } + if cfg == nil { + cfg = &config.Config{} + } + cfg.AuthDir = opened.authDir + opened.ready() + return cfg, nil +} diff --git a/cmd/server/backend_test.go b/cmd/server/backend_test.go new file mode 100644 index 00000000000..f1bdb83e1bf --- /dev/null +++ b/cmd/server/backend_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "testing" +) + +func envLookup(values map[string]string) func(keys ...string) (string, bool) { + return func(keys ...string) (string, bool) { + for _, key := range keys { + if value := strings.TrimSpace(values[key]); value != "" { + return value, true + } + } + return "", false + } +} + +func TestSelectBackendReturnsNilWithoutSettings(t *testing.T) { + if open := selectBackend(backendEnv{lookup: envLookup(nil), workDir: t.TempDir()}); open != nil { + t.Fatal("selected a backend without any settings") + } +} + +func TestSelectBackendPriority(t *testing.T) { + all := map[string]string{ + "PGSTORE_DSN": "postgres://example.invalid/db", + "OBJECTSTORE_ENDPOINT": "http://127.0.0.1:1", + "GITSTORE_GIT_URL": "https://example.invalid/repo.git", + "gitstore_git_username": "ignored", + } + tests := []struct { + name string + drop []string + want string + }{ + {"postgres first", nil, "postgres"}, + {"object next", []string{"PGSTORE_DSN"}, "object"}, + {"git last", []string{"PGSTORE_DSN", "OBJECTSTORE_ENDPOINT"}, "git"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := map[string]string{} + for k, v := range all { + env[k] = v + } + for _, k := range tt.drop { + delete(env, k) + } + be := backendEnv{lookup: envLookup(env), workDir: t.TempDir()} + var got string + for _, backend := range storeBackends { + if backend.detect(be) != nil { + got = backend.name + break + } + } + if got != tt.want { + t.Fatalf("first selected backend = %q, want %q", got, tt.want) + } + if selectBackend(be) == nil { + t.Fatal("selectBackend returned nil") + } + }) + } +} + +func TestSelectBackendAcceptsLowercaseKeys(t *testing.T) { + be := backendEnv{lookup: envLookup(map[string]string{"pgstore_dsn": "postgres://example.invalid/db"}), workDir: t.TempDir()} + if selectBackend(be) == nil { + t.Fatal("lowercase key not honored") + } +} + +func TestObjectBackendRejectsBadEndpoints(t *testing.T) { + tests := map[string]string{ + "ftp://host/bucket": "unsupported object store scheme", + "http://": "missing host", + } + for endpoint, want := range tests { + be := backendEnv{ + lookup: envLookup(map[string]string{"OBJECTSTORE_ENDPOINT": endpoint}), + workDir: t.TempDir(), + } + open := selectBackend(be) + if open == nil { + t.Fatalf("%q: backend not selected", endpoint) + } + if _, err := open(); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("%q: err = %v, want %q", endpoint, err, want) + } + } +} + +func TestSpoolBaseFallbacks(t *testing.T) { + be := backendEnv{workDir: "/work", writableBase: "/writable"} + if got := be.spoolBase("/configured"); got != "/configured" { + t.Fatalf("configured = %q", got) + } + if got := be.spoolBase(""); got != "/writable" { + t.Fatalf("writable = %q", got) + } + be.writableBase = "" + if got := be.spoolBase(""); got != "/work" { + t.Fatalf("work dir = %q", got) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 960d5e53009..66ec562ecd4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,8 +9,6 @@ import ( "flag" "fmt" "io" - "io/fs" - "net/url" "os" "path/filepath" "strconv" @@ -33,7 +31,6 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" "github.com/router-for-me/CLIProxyAPI/v8/internal/safemode" - "github.com/router-for-me/CLIProxyAPI/v8/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator" "github.com/router-for-me/CLIProxyAPI/v8/internal/tui" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" @@ -223,28 +220,7 @@ func main() { var homeClient *home.Client var homePluginSyncReport homeplugins.SyncReport var homePluginStatusReady bool - var ( - usePostgresStore bool - pgStoreDSN string - pgStoreSchema string - pgStoreLocalPath string - pgStoreInst *store.PostgresStore - useGitStore bool - gitStoreRemoteURL string - gitStoreUser string - gitStorePassword string - gitStoreBranch string - gitStoreLocalPath string - gitStoreInst *store.GitTokenStore - gitStoreRoot string - useObjectStore bool - objectStoreEndpoint string - objectStoreAccess string - objectStoreSecret string - objectStoreBucket string - objectStoreLocalPath string - objectStoreInst *store.ObjectTokenStore - ) + var opened *openedBackend wd, err := os.Getwd() if err != nil { @@ -277,59 +253,6 @@ func main() { } } - if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok { - usePostgresStore = true - pgStoreDSN = value - } - if usePostgresStore { - if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok { - pgStoreSchema = value - } - if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok { - pgStoreLocalPath = value - } - if pgStoreLocalPath == "" { - if writableBase != "" { - pgStoreLocalPath = writableBase - } else { - pgStoreLocalPath = wd - } - } - useGitStore = false - } - if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok { - useGitStore = true - gitStoreRemoteURL = value - } - if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok { - gitStoreUser = value - } - if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok { - gitStorePassword = value - } - if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok { - gitStoreLocalPath = value - } - if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok { - gitStoreBranch = value - } - if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok { - useObjectStore = true - objectStoreEndpoint = value - } - if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok { - objectStoreAccess = value - } - if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok { - objectStoreSecret = value - } - if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok { - objectStoreBucket = value - } - if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok { - objectStoreLocalPath = value - } - // Check for cloud deploy mode only on first execution // Read env var name in uppercase: DEPLOY deployEnv := os.Getenv("DEPLOY") @@ -440,150 +363,14 @@ func main() { } // Local stores are intentionally disabled when config is loaded from home. - usePostgresStore = false - useObjectStore = false - useGitStore = false - } else if usePostgresStore { - if pgStoreLocalPath == "" { - pgStoreLocalPath = wd - } - pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{ - DSN: pgStoreDSN, - Schema: pgStoreSchema, - SpoolDir: pgStoreLocalPath, - }) - cancel() + } else if open := selectBackend(backendEnv{lookup: lookupEnv, workDir: wd, writableBase: writableBase}); open != nil { + opened, err = open() if err != nil { - log.Errorf("failed to initialize postgres token store: %v", err) + log.Error(err) return } - examplePath := filepath.Join(wd, "config.example.yaml") - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil { - cancel() - log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap) - return - } - cancel() - configFilePath = pgStoreInst.ConfigPath() - cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) - if err == nil { - cfg.AuthDir = pgStoreInst.AuthDir() - log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir()) - } - } else if useObjectStore { - if objectStoreLocalPath == "" { - if writableBase != "" { - objectStoreLocalPath = writableBase - } else { - objectStoreLocalPath = wd - } - } - objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore") - resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint) - useSSL := true - if strings.Contains(resolvedEndpoint, "://") { - parsed, errParse := url.Parse(resolvedEndpoint) - if errParse != nil { - log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse) - return - } - switch strings.ToLower(parsed.Scheme) { - case "http": - useSSL = false - case "https": - useSSL = true - default: - log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme) - return - } - if parsed.Host == "" { - log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint) - return - } - resolvedEndpoint = parsed.Host - if parsed.Path != "" && parsed.Path != "/" { - resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/") - } - } - resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/") - objCfg := store.ObjectStoreConfig{ - Endpoint: resolvedEndpoint, - Bucket: objectStoreBucket, - AccessKey: objectStoreAccess, - SecretKey: objectStoreSecret, - LocalRoot: objectStoreRoot, - UseSSL: useSSL, - PathStyle: true, - } - objectStoreInst, err = store.NewObjectTokenStore(objCfg) - if err != nil { - log.Errorf("failed to initialize object token store: %v", err) - return - } - examplePath := filepath.Join(wd, "config.example.yaml") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil { - cancel() - log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap) - return - } - cancel() - configFilePath = objectStoreInst.ConfigPath() - cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) - if err == nil { - if cfg == nil { - cfg = &config.Config{} - } - cfg.AuthDir = objectStoreInst.AuthDir() - log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket) - } - } else if useGitStore { - if gitStoreLocalPath == "" { - if writableBase != "" { - gitStoreLocalPath = writableBase - } else { - gitStoreLocalPath = wd - } - } - gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore") - authDir := filepath.Join(gitStoreRoot, "auths") - gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch) - gitStoreInst.SetBaseDir(authDir) - if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil { - log.Errorf("failed to prepare git token store: %v", errRepo) - return - } - configFilePath = gitStoreInst.ConfigPath() - if configFilePath == "" { - configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml") - } - if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) { - examplePath := filepath.Join(wd, "config.example.yaml") - if _, errExample := os.Stat(examplePath); errExample != nil { - log.Errorf("failed to find template config file: %v", errExample) - return - } - if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil { - log.Errorf("failed to bootstrap git-backed config: %v", errCopy) - return - } - if errCommit := persistSeededConfig(context.Background(), configFilePath, gitStoreInst.PersistConfig); errCommit != nil { - log.Errorf("failed to commit initial git-backed config: %v", errCommit) - return - } - log.Infof("git-backed config initialized from template: %s", configFilePath) - } else if statErr != nil { - log.Errorf("failed to inspect git-backed config: %v", statErr) - return - } - cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) - if err == nil { - cfg.AuthDir = gitStoreInst.AuthDir() - log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot) - } + configFilePath = opened.configPath + cfg, err = loadBackendConfig(opened, isCloudDeploy) } else if configPath != "" { configFilePath = configPath cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy) @@ -670,12 +457,8 @@ func main() { } // Register the shared token store once so all components use the same persistence backend. - if usePostgresStore { - sdkAuth.RegisterTokenStore(pgStoreInst) - } else if useObjectStore { - sdkAuth.RegisterTokenStore(objectStoreInst) - } else if useGitStore { - sdkAuth.RegisterTokenStore(gitStoreInst) + if opened != nil { + sdkAuth.RegisterTokenStore(opened.tokenStore) } else { sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore()) }