Files
ez-api/cmd/seeder/main.go
zenfun 18b9846f83 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.
2026-01-10 00:26:48 +08:00

288 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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