feat(migrate): add import CLI command and importer for migration data

Introduce a new `import` subcommand to the server binary that reads
exported JSON files and imports masters, providers, keys, bindings,
and namespaces into the database.

Key features:
- Support for dry-run mode to validate without writing
- Conflict policies: skip existing or overwrite
- Optional binding import via --include-bindings flag
- Auto-generation of master keys with secure hashing
- Namespace auto-creation for referenced namespaces
- Detailed import summary with warnings and created credentials
This commit is contained in:
zenfun
2025-12-23 20:13:45 +08:00
parent ee6c28afc9
commit cd5616dc26
3 changed files with 776 additions and 0 deletions

View File

@@ -2,7 +2,10 @@ package main
import (
"context"
"encoding/json"
"expvar"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
@@ -16,6 +19,7 @@ import (
"github.com/ez-api/ez-api/internal/config"
"github.com/ez-api/ez-api/internal/cron"
"github.com/ez-api/ez-api/internal/middleware"
"github.com/ez-api/ez-api/internal/migrate"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/ez-api/foundation/logging"
@@ -72,6 +76,10 @@ func isOriginAllowed(allowed []string, origin string) bool {
func main() {
logger, _ := logging.New(logging.Options{Service: "ez-api"})
if len(os.Args) > 1 && os.Args[1] == "import" {
code := runImport(logger, os.Args[2:])
os.Exit(code)
}
// 1. Load Configuration
cfg, err := config.Load()
@@ -357,3 +365,71 @@ func main() {
logger.Info("server exited properly")
}
func runImport(logger *slog.Logger, args []string) int {
fs := flag.NewFlagSet("import", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var filePath string
var dryRun bool
var conflictPolicy string
var includeBindings bool
fs.StringVar(&filePath, "file", "", "Path to export JSON")
fs.BoolVar(&dryRun, "dry-run", false, "Validate only, do not write to database")
fs.StringVar(&conflictPolicy, "conflict", migrate.ConflictSkip, "Conflict policy: skip or overwrite")
fs.BoolVar(&includeBindings, "include-bindings", false, "Import bindings from payload")
if err := fs.Parse(args); err != nil {
logger.Error("failed to parse flags", "err", err)
return 2
}
if strings.TrimSpace(filePath) == "" {
fmt.Fprintln(os.Stderr, "missing --file")
return 2
}
conflictPolicy = strings.ToLower(strings.TrimSpace(conflictPolicy))
if conflictPolicy != migrate.ConflictSkip && conflictPolicy != migrate.ConflictOverwrite {
fmt.Fprintf(os.Stderr, "invalid --conflict value: %s\n", conflictPolicy)
return 2
}
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load config", "err", err)
return 1
}
db, err := gorm.Open(postgres.Open(cfg.Postgres.DSN), &gorm.Config{})
if err != nil {
logger.Error("failed to connect to postgresql", "err", err)
return 1
}
if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.Namespace{}); err != nil {
logger.Error("failed to auto migrate", "err", err)
return 1
}
importer := migrate.NewImporter(db, migrate.ImportOptions{
DryRun: dryRun,
ConflictPolicy: conflictPolicy,
IncludeBindings: includeBindings,
})
summary, err := importer.ImportFile(filePath)
if err != nil {
logger.Error("import failed", "err", err)
return 1
}
payload, err := json.MarshalIndent(summary, "", " ")
if err != nil {
logger.Error("failed to render import summary", "err", err)
return 1
}
fmt.Fprintln(os.Stdout, string(payload))
if dryRun {
fmt.Fprintln(os.Stdout, "dry-run only: no data written")
}
return 0
}