feat(seeder): add control plane data seeder

Introduce a `cmd/seeder` CLI to generate deterministic demo datasets and
seed them into the Control Plane via admin endpoints, supporting reset,
dry-run, profiles, and usage sample generation.

Add Cobra/Viper dependencies to support the new command.
This commit is contained in:
zenfun
2026-01-10 00:26:48 +08:00
parent 33838b1e2c
commit 18b9846f83
6 changed files with 1679 additions and 0 deletions

287
cmd/seeder/main.go Normal file
View File

@@ -0,0 +1,287 @@
package main
import (
"fmt"
"math/rand"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Config holds seeder configuration
type Config struct {
BaseURL string
AdminToken string
Profile string
Seed int64
UsageDays int
Env string
Reset bool
DryRun bool
Force bool
Verbose bool
}
// ProfileConfig defines entity counts for a profile
type ProfileConfig struct {
Namespaces int
ProviderGroups int
ProvidersPerGroup int
Models int
Bindings int
Masters int
KeysPerMaster int
UsageDays int
}
var profiles = map[string]ProfileConfig{
"demo": {
Namespaces: 2,
ProviderGroups: 2,
ProvidersPerGroup: 2,
Models: 5,
Bindings: 4,
Masters: 2,
KeysPerMaster: 3,
UsageDays: 7,
},
"load": {
Namespaces: 5,
ProviderGroups: 10,
ProvidersPerGroup: 3,
Models: 30,
Bindings: 50,
Masters: 50,
KeysPerMaster: 2,
UsageDays: 30,
},
"dev": {
Namespaces: 3,
ProviderGroups: 5,
ProvidersPerGroup: 3,
Models: 20,
Bindings: 15,
Masters: 10,
KeysPerMaster: 2,
UsageDays: 14,
},
}
func main() {
var cfg Config
rootCmd := &cobra.Command{
Use: "seeder",
Short: "EZ-API Demo Data Seeder",
Long: `A CLI tool to seed demo data into EZ-API Control Plane.
Creates namespaces, provider groups, providers, models, bindings,
masters, API keys, and usage samples for frontend development and demos.`,
RunE: func(cmd *cobra.Command, args []string) error {
return run(cfg)
},
}
// Bind flags
rootCmd.Flags().StringVar(&cfg.BaseURL, "base-url", "http://localhost:8080", "Control Plane API base URL")
rootCmd.Flags().StringVar(&cfg.AdminToken, "admin-token", "", "Admin authentication token (required)")
rootCmd.Flags().StringVar(&cfg.Profile, "profile", "demo", "Dataset profile: demo, load, dev")
rootCmd.Flags().Int64Var(&cfg.Seed, "seed", 0, "Fixed random seed for reproducibility (0 = random)")
rootCmd.Flags().IntVar(&cfg.UsageDays, "usage-days", 0, "Time window for usage samples (0 = profile default)")
rootCmd.Flags().StringVar(&cfg.Env, "env", "dev", "Environment: dev, test, staging, production")
rootCmd.Flags().BoolVar(&cfg.Reset, "reset", false, "Remove previous seeder data before recreating")
rootCmd.Flags().BoolVar(&cfg.DryRun, "dry-run", false, "Print actions without executing write API calls")
rootCmd.Flags().BoolVar(&cfg.Force, "force", false, "Allow running against production")
rootCmd.Flags().BoolVar(&cfg.Verbose, "verbose", false, "Print detailed progress")
// Bind environment variables
viper.SetEnvPrefix("SEEDER")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
// Override from env if not set via flag
rootCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("base-url") && viper.IsSet("BASE_URL") {
cfg.BaseURL = viper.GetString("BASE_URL")
}
if !cmd.Flags().Changed("admin-token") && viper.IsSet("ADMIN_TOKEN") {
cfg.AdminToken = viper.GetString("ADMIN_TOKEN")
}
if !cmd.Flags().Changed("profile") && viper.IsSet("PROFILE") {
cfg.Profile = viper.GetString("PROFILE")
}
if !cmd.Flags().Changed("seed") && viper.IsSet("SEED") {
cfg.Seed = viper.GetInt64("SEED")
}
if !cmd.Flags().Changed("usage-days") && viper.IsSet("USAGE_DAYS") {
cfg.UsageDays = viper.GetInt("USAGE_DAYS")
}
if !cmd.Flags().Changed("env") && viper.IsSet("ENV") {
cfg.Env = viper.GetString("ENV")
}
return nil
}
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func run(cfg Config) error {
// Validate required fields
if cfg.AdminToken == "" {
return fmt.Errorf("admin-token is required (use --admin-token or SEEDER_ADMIN_TOKEN)")
}
// Validate profile
profile, ok := profiles[cfg.Profile]
if !ok {
return fmt.Errorf("invalid profile: %s (valid: demo, load, dev)", cfg.Profile)
}
// Environment guard
if isProduction(cfg.Env) && !cfg.Force {
return fmt.Errorf("refusing to run against production environment (env=%s). Use --force to override", cfg.Env)
}
// Initialize seed
if cfg.Seed == 0 {
cfg.Seed = time.Now().UnixNano()
}
// Use profile default for usage days if not specified
if cfg.UsageDays == 0 {
cfg.UsageDays = profile.UsageDays
}
// Create seeder tag
seederTag := fmt.Sprintf("seeder:%s:seed-%d", cfg.Profile, cfg.Seed)
// Print banner
printBanner(cfg, seederTag)
// Warning for production
if isProduction(cfg.Env) && cfg.Force {
fmt.Println("\n⚠ WARNING: Running against PRODUCTION environment!")
fmt.Println(" Press Ctrl+C within 5 seconds to abort...")
time.Sleep(5 * time.Second)
fmt.Println(" Proceeding...")
}
// Create seeder instance
seeder := NewSeeder(cfg, profile, seederTag)
// Run seeding
return seeder.Run()
}
func isProduction(env string) bool {
env = strings.ToLower(env)
return env == "production" || env == "prod"
}
func printBanner(cfg Config, seederTag string) {
mode := "create"
if cfg.Reset {
mode = "reset + create"
}
if cfg.DryRun {
mode += " (dry-run)"
}
fmt.Println()
fmt.Println("EZ-API Demo Data Seeder")
fmt.Println("=======================")
fmt.Printf("Profile: %s\n", cfg.Profile)
fmt.Printf("Seed: %d\n", cfg.Seed)
fmt.Printf("Target: %s\n", cfg.BaseURL)
fmt.Printf("Environment: %s\n", cfg.Env)
fmt.Printf("Usage Window: %d days\n", cfg.UsageDays)
fmt.Printf("Mode: %s\n", mode)
fmt.Printf("Seeder Tag: %s\n", seederTag)
fmt.Println()
}
// Seeder handles the seeding process
type Seeder struct {
cfg Config
profile ProfileConfig
seederTag string
client *Client
rng *rand.Rand
summary Summary
}
// Summary tracks created/skipped counts
type Summary struct {
Namespaces CountPair
ProviderGroups CountPair
Providers CountPair
Models CountPair
Bindings CountPair
Masters CountPair
Keys CountPair
UsageSamples int
}
type CountPair struct {
Created int
Skipped int
}
// NewSeeder creates a new seeder instance
func NewSeeder(cfg Config, profile ProfileConfig, seederTag string) *Seeder {
return &Seeder{
cfg: cfg,
profile: profile,
seederTag: seederTag,
client: NewClient(cfg.BaseURL, cfg.AdminToken, cfg.DryRun, cfg.Verbose),
rng: rand.New(rand.NewSource(cfg.Seed)),
}
}
// Run executes the seeding process
func (s *Seeder) Run() error {
steps := []struct {
name string
fn func() error
}{
{"Creating namespaces", s.seedNamespaces},
{"Creating provider groups", s.seedProviderGroups},
{"Creating providers", s.seedProviders},
{"Creating models", s.seedModels},
{"Creating bindings", s.seedBindings},
{"Creating masters", s.seedMasters},
{"Creating API keys", s.seedKeys},
{"Generating usage samples", s.seedUsageSamples},
}
for i, step := range steps {
fmt.Printf("[%d/%d] %s...\n", i+1, len(steps), step.name)
if err := step.fn(); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
s.printSummary()
return nil
}
func (s *Seeder) printSummary() {
fmt.Println("\nDone! Summary:")
fmt.Printf(" Namespaces: %d created, %d skipped\n", s.summary.Namespaces.Created, s.summary.Namespaces.Skipped)
fmt.Printf(" Provider Groups: %d created, %d skipped\n", s.summary.ProviderGroups.Created, s.summary.ProviderGroups.Skipped)
fmt.Printf(" Providers: %d created, %d skipped\n", s.summary.Providers.Created, s.summary.Providers.Skipped)
fmt.Printf(" Models: %d created, %d skipped\n", s.summary.Models.Created, s.summary.Models.Skipped)
fmt.Printf(" Bindings: %d created, %d skipped\n", s.summary.Bindings.Created, s.summary.Bindings.Skipped)
fmt.Printf(" Masters: %d created, %d skipped\n", s.summary.Masters.Created, s.summary.Masters.Skipped)
fmt.Printf(" API Keys: %d created, %d skipped\n", s.summary.Keys.Created, s.summary.Keys.Skipped)
fmt.Printf(" Usage Samples: %d entries\n", s.summary.UsageSamples)
fmt.Printf("\nSeeder tag: %s\n", s.seederTag)
fmt.Printf("\nDashboard should now show data at:\n %s/admin/dashboard\n", s.cfg.BaseURL)
}
// Seed methods are implemented in seeder.go