sync: clean stale local pricelists and migrate runtime config handling

This commit is contained in:
2026-02-08 10:01:27 +03:00
parent ceba2f258d
commit d942623354
12 changed files with 434 additions and 426 deletions
+23 -35
View File
@@ -7,7 +7,6 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
@@ -16,7 +15,6 @@ import (
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
@@ -28,22 +26,6 @@ func main() {
log.Println("QuoteForge Configuration Migration Tool")
log.Println("========================================")
// Load config for MariaDB connection
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Connect to MariaDB
log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port)
mariaDB, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to MariaDB: %v", err)
}
log.Println("Connected to MariaDB")
// Initialize local SQLite
log.Printf("Opening local SQLite at %s...", *localDBPath)
local, err := localdb.New(*localDBPath)
@@ -51,6 +33,28 @@ func main() {
log.Fatalf("Failed to initialize local database: %v", err)
}
log.Println("Local SQLite initialized")
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
settings, err := local.GetSettings()
if err != nil {
log.Fatalf("Failed to load SQLite connection settings: %v", err)
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("Failed to build DSN from SQLite settings: %v", err)
}
// Connect to MariaDB
log.Printf("Connecting to MariaDB at %s:%d...", settings.Host, settings.Port)
mariaDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to MariaDB: %v", err)
}
log.Println("Connected to MariaDB")
// Count configurations in MariaDB
var serverCount int64
@@ -149,23 +153,7 @@ func main() {
log.Printf(" Skipped: %d", skipped)
log.Printf(" Errors: %d", errors)
// Save connection settings to local SQLite if not exists
if !local.HasSettings() {
log.Println("\nSaving connection settings to local SQLite...")
if err := local.SaveSettings(
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Name,
cfg.Database.User,
cfg.Database.Password,
); err != nil {
log.Printf("Warning: Failed to save settings: %v", err)
} else {
log.Println("Connection settings saved")
}
}
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs")
}
func derefUint(v *uint) uint {
+19 -6
View File
@@ -10,7 +10,8 @@ import (
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/google/uuid"
"gorm.io/driver/mysql"
@@ -38,17 +39,29 @@ type migrationAction struct {
}
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
flag.Parse()
cfg, err := config.Load(*configPath)
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
log.Fatalf("failed to initialize local database: %v", err)
}
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
}
dbUser := strings.TrimSpace(local.GetDBUser())
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -59,7 +72,7 @@ func main() {
log.Fatalf("precheck failed: %v", err)
}
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
actions, existingProjects, err := buildPlan(db, dbUser)
if err != nil {
log.Fatalf("failed to build migration plan: %v", err)
}
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/config"
)
func TestMigrateConfigFileToRuntimeShapeDropsDeprecatedSections(t *testing.T) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
legacy := `server:
host: "0.0.0.0"
port: 9191
database:
host: "legacy-db"
port: 3306
name: "RFQ_LOG"
user: "old"
password: "REDACTED_TEST_PASSWORD"
pricing:
default_method: "median"
logging:
level: "debug"
format: "text"
output: "stdout"
`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatalf("write legacy config: %v", err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("load legacy config: %v", err)
}
setConfigDefaults(cfg)
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
t.Fatalf("migrate config: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read migrated config: %v", err)
}
text := string(got)
if strings.Contains(text, "database:") {
t.Fatalf("migrated config still contains deprecated database section:\n%s", text)
}
if strings.Contains(text, "pricing:") {
t.Fatalf("migrated config still contains deprecated pricing section:\n%s", text)
}
if !strings.Contains(text, "server:") || !strings.Contains(text, "logging:") {
t.Fatalf("migrated config missing required sections:\n%s", text)
}
if !strings.Contains(text, "port: 9191") {
t.Fatalf("migrated config did not preserve server port:\n%s", text)
}
if !strings.Contains(text, "level: debug") {
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
}
}
+106 -6
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"errors"
"flag"
@@ -34,6 +35,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -62,18 +64,18 @@ func main() {
slog.Info("starting qfs", "version", Version, "executable", exePath)
appmeta.SetVersion(Version)
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
if err != nil {
slog.Error("failed to resolve local database path", "error", err)
os.Exit(1)
}
resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
if err != nil {
slog.Error("failed to resolve config path", "error", err)
os.Exit(1)
}
// Migrate legacy project-local config path to the user state directory when using defaults.
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
@@ -113,6 +115,10 @@ func main() {
}
// Load config for server settings (optional)
if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
cfg, err := config.Load(resolvedConfigPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -125,6 +131,10 @@ func main() {
}
}
setConfigDefaults(cfg)
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
setupLogger(cfg.Logging)
@@ -316,6 +326,96 @@ func setConfigDefaults(cfg *config.Config) {
}
}
func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty")
}
if _, err := os.Stat(configPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return err
}
const defaultConfigYAML = `server:
host: "127.0.0.1"
port: 8080
mode: "release"
read_timeout: 30s
write_timeout: 30s
logging:
level: "info"
format: "json"
output: "stdout"
`
if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
return err
}
slog.Info("created default config file", "path", configPath)
return nil
}
type runtimeServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Mode string `yaml:"mode"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
}
type runtimeLoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
}
type runtimeConfigFile struct {
Server runtimeServerConfig `yaml:"server"`
Logging runtimeLoggingConfig `yaml:"logging"`
}
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
// Deprecated sections from legacy configs are intentionally dropped.
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
runtimeCfg := runtimeConfigFile{
Server: runtimeServerConfig{
Host: cfg.Server.Host,
Port: cfg.Server.Port,
Mode: cfg.Server.Mode,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
},
Logging: runtimeLoggingConfig{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
Output: cfg.Logging.Output,
},
}
rendered, err := yaml.Marshal(&runtimeCfg)
if err != nil {
return fmt.Errorf("marshal runtime config: %w", err)
}
current, err := os.ReadFile(configPath)
if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
return nil
}
if err := os.WriteFile(configPath, rendered, 0644); err != nil {
return fmt.Errorf("write runtime config: %w", err)
}
slog.Info("migrated config.yaml to runtime format", "path", configPath)
return nil
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)