something
This commit is contained in:
152
cmd/jiggablend/cmd/manager.go
Normal file
152
cmd/jiggablend/cmd/manager.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"jiggablend/internal/api"
|
||||
"jiggablend/internal/auth"
|
||||
"jiggablend/internal/config"
|
||||
"jiggablend/internal/database"
|
||||
"jiggablend/internal/logger"
|
||||
"jiggablend/internal/storage"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var managerCmd = &cobra.Command{
|
||||
Use: "manager",
|
||||
Short: "Start the Jiggablend manager server",
|
||||
Long: `Start the Jiggablend manager server to coordinate render jobs.`,
|
||||
Run: runManager,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(managerCmd)
|
||||
|
||||
// Flags with env binding via viper
|
||||
managerCmd.Flags().StringP("port", "p", "8080", "Server port")
|
||||
managerCmd.Flags().String("db", "jiggablend.db", "Database path")
|
||||
managerCmd.Flags().String("storage", "./jiggablend-storage", "Storage path")
|
||||
managerCmd.Flags().StringP("log-file", "l", "", "Log file path (truncated on start, if not set logs only to stdout)")
|
||||
managerCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
|
||||
managerCmd.Flags().BoolP("verbose", "v", false, "Enable verbose logging (same as --log-level=debug)")
|
||||
|
||||
// Bind flags to viper with JIGGABLEND_ prefix
|
||||
viper.SetEnvPrefix("JIGGABLEND")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
viper.BindPFlag("port", managerCmd.Flags().Lookup("port"))
|
||||
viper.BindPFlag("db", managerCmd.Flags().Lookup("db"))
|
||||
viper.BindPFlag("storage", managerCmd.Flags().Lookup("storage"))
|
||||
viper.BindPFlag("log_file", managerCmd.Flags().Lookup("log-file"))
|
||||
viper.BindPFlag("log_level", managerCmd.Flags().Lookup("log-level"))
|
||||
viper.BindPFlag("verbose", managerCmd.Flags().Lookup("verbose"))
|
||||
}
|
||||
|
||||
func runManager(cmd *cobra.Command, args []string) {
|
||||
// Get config values (flags take precedence over env vars)
|
||||
port := viper.GetString("port")
|
||||
dbPath := viper.GetString("db")
|
||||
storagePath := viper.GetString("storage")
|
||||
logFile := viper.GetString("log_file")
|
||||
logLevel := viper.GetString("log_level")
|
||||
verbose := viper.GetBool("verbose")
|
||||
|
||||
// Initialize logger
|
||||
if logFile != "" {
|
||||
if err := logger.InitWithFile(logFile); err != nil {
|
||||
logger.Fatalf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if l := logger.GetDefault(); l != nil {
|
||||
l.Close()
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.InitStdout()
|
||||
}
|
||||
|
||||
// Set log level
|
||||
if verbose {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
} else {
|
||||
logger.SetLevel(logger.ParseLevel(logLevel))
|
||||
}
|
||||
|
||||
if logFile != "" {
|
||||
logger.Infof("Logging to file: %s", logFile)
|
||||
}
|
||||
logger.Debugf("Log level: %s", logLevel)
|
||||
|
||||
// Initialize database
|
||||
db, err := database.NewDB(dbPath)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize config from database
|
||||
cfg := config.NewConfig(db)
|
||||
if err := cfg.InitializeFromEnv(); err != nil {
|
||||
logger.Fatalf("Failed to initialize config: %v", err)
|
||||
}
|
||||
logger.Info("Configuration loaded from database")
|
||||
|
||||
// Initialize auth
|
||||
authHandler, err := auth.NewAuth(db, cfg)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize auth: %v", err)
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
storageHandler, err := storage.NewStorage(storagePath)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
|
||||
// Check if Blender is available
|
||||
if err := checkBlenderAvailable(); err != nil {
|
||||
logger.Fatalf("Blender is not available: %v\n"+
|
||||
"The manager requires Blender to be installed and in PATH for metadata extraction.\n"+
|
||||
"Please install Blender and ensure it's accessible via the 'blender' command.", err)
|
||||
}
|
||||
logger.Info("Blender is available")
|
||||
|
||||
// Create API server
|
||||
server, err := api.NewServer(db, cfg, authHandler, storageHandler)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server
|
||||
addr := fmt.Sprintf(":%s", port)
|
||||
logger.Infof("Starting manager server on %s", addr)
|
||||
logger.Infof("Database: %s", dbPath)
|
||||
logger.Infof("Storage: %s", storagePath)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadTimeout: 0,
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
|
||||
if err := httpServer.ListenAndServe(); err != nil {
|
||||
logger.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkBlenderAvailable() error {
|
||||
cmd := exec.Command("blender", "--version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run 'blender --version': %w (output: %s)", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
621
cmd/jiggablend/cmd/managerconfig.go
Normal file
621
cmd/jiggablend/cmd/managerconfig.go
Normal file
@@ -0,0 +1,621 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"jiggablend/internal/config"
|
||||
"jiggablend/internal/database"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
configDBPath string
|
||||
configYes bool // Auto-confirm prompts
|
||||
configForce bool // Force override existing
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configure the manager",
|
||||
Long: `Configure the Jiggablend manager settings stored in the database.`,
|
||||
}
|
||||
|
||||
// --- Enable/Disable commands ---
|
||||
|
||||
var enableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable a feature",
|
||||
}
|
||||
|
||||
var disableCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable a feature",
|
||||
}
|
||||
|
||||
var enableLocalAuthCmd = &cobra.Command{
|
||||
Use: "localauth",
|
||||
Short: "Enable local authentication",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyEnableLocalAuth, true); err != nil {
|
||||
exitWithError("Failed to enable local auth: %v", err)
|
||||
}
|
||||
fmt.Println("Local authentication enabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var disableLocalAuthCmd = &cobra.Command{
|
||||
Use: "localauth",
|
||||
Short: "Disable local authentication",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyEnableLocalAuth, false); err != nil {
|
||||
exitWithError("Failed to disable local auth: %v", err)
|
||||
}
|
||||
fmt.Println("Local authentication disabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var enableRegistrationCmd = &cobra.Command{
|
||||
Use: "registration",
|
||||
Short: "Enable user registration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyRegistrationEnabled, true); err != nil {
|
||||
exitWithError("Failed to enable registration: %v", err)
|
||||
}
|
||||
fmt.Println("User registration enabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var disableRegistrationCmd = &cobra.Command{
|
||||
Use: "registration",
|
||||
Short: "Disable user registration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyRegistrationEnabled, false); err != nil {
|
||||
exitWithError("Failed to disable registration: %v", err)
|
||||
}
|
||||
fmt.Println("User registration disabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var enableProductionCmd = &cobra.Command{
|
||||
Use: "production",
|
||||
Short: "Enable production mode",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyProductionMode, true); err != nil {
|
||||
exitWithError("Failed to enable production mode: %v", err)
|
||||
}
|
||||
fmt.Println("Production mode enabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var disableProductionCmd = &cobra.Command{
|
||||
Use: "production",
|
||||
Short: "Disable production mode",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.SetBool(config.KeyProductionMode, false); err != nil {
|
||||
exitWithError("Failed to disable production mode: %v", err)
|
||||
}
|
||||
fmt.Println("Production mode disabled")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// --- Add commands ---
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a resource",
|
||||
}
|
||||
|
||||
var (
|
||||
addUserName string
|
||||
addUserAdmin bool
|
||||
)
|
||||
|
||||
var addUserCmd = &cobra.Command{
|
||||
Use: "user <email> <password>",
|
||||
Short: "Add a local user",
|
||||
Long: `Add a new local user account to the database.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
email := args[0]
|
||||
password := args[1]
|
||||
|
||||
name := addUserName
|
||||
if name == "" {
|
||||
// Use email prefix as name
|
||||
if atIndex := strings.Index(email, "@"); atIndex > 0 {
|
||||
name = email[:atIndex]
|
||||
} else {
|
||||
name = email
|
||||
}
|
||||
}
|
||||
if len(password) < 8 {
|
||||
exitWithError("Password must be at least 8 characters")
|
||||
}
|
||||
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
// Check if user exists
|
||||
var exists bool
|
||||
err := db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", email).Scan(&exists)
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to check user: %v", err)
|
||||
}
|
||||
isAdmin := addUserAdmin
|
||||
if exists {
|
||||
if !configForce {
|
||||
exitWithError("User with email %s already exists (use -f to override)", email)
|
||||
}
|
||||
// Confirm override
|
||||
if !configYes && !confirm(fmt.Sprintf("User %s already exists. Override?", email)) {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
// Update existing user
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
exitWithError("Failed to hash password: %v", err)
|
||||
}
|
||||
err = db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec(
|
||||
"UPDATE users SET name = ?, password_hash = ?, is_admin = ? WHERE email = ?",
|
||||
name, string(hashedPassword), isAdmin, email,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to update user: %v", err)
|
||||
}
|
||||
fmt.Printf("Updated user: %s (admin: %v)\n", email, isAdmin)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
exitWithError("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Check if first user (make admin)
|
||||
var userCount int
|
||||
db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
|
||||
})
|
||||
if userCount == 0 {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
// Confirm creation
|
||||
if !configYes && !confirm(fmt.Sprintf("Create user %s (admin: %v)?", email, isAdmin)) {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
err = db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec(
|
||||
"INSERT INTO users (email, name, oauth_provider, oauth_id, password_hash, is_admin) VALUES (?, ?, 'local', ?, ?, ?)",
|
||||
email, name, email, string(hashedPassword), isAdmin,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created user: %s (admin: %v)\n", email, isAdmin)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var addAPIKeyScope string
|
||||
|
||||
var addAPIKeyCmd = &cobra.Command{
|
||||
Use: "apikey [name]",
|
||||
Short: "Add a runner API key",
|
||||
Long: `Generate a new API key for runner authentication.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
name := "cli-generated"
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
}
|
||||
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
// Check if API key with same name exists
|
||||
var exists bool
|
||||
err := db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM runner_api_keys WHERE name = ?)", name).Scan(&exists)
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to check API key: %v", err)
|
||||
}
|
||||
if exists {
|
||||
if !configForce {
|
||||
exitWithError("API key with name %s already exists (use -f to create another)", name)
|
||||
}
|
||||
if !configYes && !confirm(fmt.Sprintf("API key named '%s' already exists. Create another?", name)) {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm creation
|
||||
if !configYes && !confirm(fmt.Sprintf("Generate new API key '%s' (scope: %s)?", name, addAPIKeyScope)) {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
key, keyPrefix, keyHash, err := generateAPIKey()
|
||||
if err != nil {
|
||||
exitWithError("Failed to generate API key: %v", err)
|
||||
}
|
||||
|
||||
// Get first user ID for created_by (or use 0 if no users)
|
||||
var createdBy int64
|
||||
db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT id FROM users ORDER BY id ASC LIMIT 1").Scan(&createdBy)
|
||||
})
|
||||
|
||||
// Store in database
|
||||
err = db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec(
|
||||
`INSERT INTO runner_api_keys (key_prefix, key_hash, name, scope, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, true, ?)`,
|
||||
keyPrefix, keyHash, name, addAPIKeyScope, createdBy,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to store API key: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated API key: %s\n", key)
|
||||
fmt.Printf("Name: %s, Scope: %s\n", name, addAPIKeyScope)
|
||||
fmt.Println("\nSave this key - it cannot be retrieved later!")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// --- Set commands ---
|
||||
|
||||
var setCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Set a configuration value",
|
||||
}
|
||||
|
||||
var setFixedAPIKeyCmd = &cobra.Command{
|
||||
Use: "fixed-apikey [key]",
|
||||
Short: "Set a fixed API key for testing",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
// Check if already set
|
||||
existing := cfg.FixedAPIKey()
|
||||
if existing != "" && !configForce {
|
||||
exitWithError("Fixed API key already set (use -f to override)")
|
||||
}
|
||||
if existing != "" && !configYes && !confirm("Fixed API key already set. Override?") {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
if err := cfg.Set(config.KeyFixedAPIKey, args[0]); err != nil {
|
||||
exitWithError("Failed to set fixed API key: %v", err)
|
||||
}
|
||||
fmt.Println("Fixed API key set")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var setAllowedOriginsCmd = &cobra.Command{
|
||||
Use: "allowed-origins [origins]",
|
||||
Short: "Set allowed CORS origins (comma-separated)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
if err := cfg.Set(config.KeyAllowedOrigins, args[0]); err != nil {
|
||||
exitWithError("Failed to set allowed origins: %v", err)
|
||||
}
|
||||
fmt.Printf("Allowed origins set to: %s\n", args[0])
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var setGoogleOAuthRedirectURL string
|
||||
|
||||
var setGoogleOAuthCmd = &cobra.Command{
|
||||
Use: "google-oauth <client-id> <client-secret>",
|
||||
Short: "Set Google OAuth credentials",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
clientID := args[0]
|
||||
clientSecret := args[1]
|
||||
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
// Check if already configured
|
||||
existing := cfg.GoogleClientID()
|
||||
if existing != "" && !configForce {
|
||||
exitWithError("Google OAuth already configured (use -f to override)")
|
||||
}
|
||||
if existing != "" && !configYes && !confirm("Google OAuth already configured. Override?") {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
if err := cfg.Set(config.KeyGoogleClientID, clientID); err != nil {
|
||||
exitWithError("Failed to set Google client ID: %v", err)
|
||||
}
|
||||
if err := cfg.Set(config.KeyGoogleClientSecret, clientSecret); err != nil {
|
||||
exitWithError("Failed to set Google client secret: %v", err)
|
||||
}
|
||||
if setGoogleOAuthRedirectURL != "" {
|
||||
if err := cfg.Set(config.KeyGoogleRedirectURL, setGoogleOAuthRedirectURL); err != nil {
|
||||
exitWithError("Failed to set Google redirect URL: %v", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Google OAuth configured")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var setDiscordOAuthRedirectURL string
|
||||
|
||||
var setDiscordOAuthCmd = &cobra.Command{
|
||||
Use: "discord-oauth <client-id> <client-secret>",
|
||||
Short: "Set Discord OAuth credentials",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
clientID := args[0]
|
||||
clientSecret := args[1]
|
||||
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
// Check if already configured
|
||||
existing := cfg.DiscordClientID()
|
||||
if existing != "" && !configForce {
|
||||
exitWithError("Discord OAuth already configured (use -f to override)")
|
||||
}
|
||||
if existing != "" && !configYes && !confirm("Discord OAuth already configured. Override?") {
|
||||
fmt.Println("Aborted")
|
||||
return
|
||||
}
|
||||
if err := cfg.Set(config.KeyDiscordClientID, clientID); err != nil {
|
||||
exitWithError("Failed to set Discord client ID: %v", err)
|
||||
}
|
||||
if err := cfg.Set(config.KeyDiscordClientSecret, clientSecret); err != nil {
|
||||
exitWithError("Failed to set Discord client secret: %v", err)
|
||||
}
|
||||
if setDiscordOAuthRedirectURL != "" {
|
||||
if err := cfg.Set(config.KeyDiscordRedirectURL, setDiscordOAuthRedirectURL); err != nil {
|
||||
exitWithError("Failed to set Discord redirect URL: %v", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Discord OAuth configured")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// --- Show command ---
|
||||
|
||||
var showCmd = &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current configuration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
all, err := cfg.GetAll()
|
||||
if err != nil {
|
||||
exitWithError("Failed to get config: %v", err)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
fmt.Println("No configuration stored")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Current configuration:")
|
||||
fmt.Println("----------------------")
|
||||
for key, value := range all {
|
||||
// Redact sensitive values
|
||||
if strings.Contains(key, "secret") || strings.Contains(key, "api_key") || strings.Contains(key, "password") {
|
||||
fmt.Printf(" %s: [REDACTED]\n", key)
|
||||
} else {
|
||||
fmt.Printf(" %s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// --- List commands ---
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List resources",
|
||||
}
|
||||
|
||||
var listUsersCmd = &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "List all users",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
var rows *sql.Rows
|
||||
err := db.With(func(conn *sql.DB) error {
|
||||
var err error
|
||||
rows, err = conn.Query("SELECT id, email, name, oauth_provider, is_admin, created_at FROM users ORDER BY id")
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to list users: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Printf("%-6s %-30s %-20s %-10s %-6s %s\n", "ID", "Email", "Name", "Provider", "Admin", "Created")
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var email, name, provider string
|
||||
var isAdmin bool
|
||||
var createdAt string
|
||||
if err := rows.Scan(&id, &email, &name, &provider, &isAdmin, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
adminStr := "no"
|
||||
if isAdmin {
|
||||
adminStr = "yes"
|
||||
}
|
||||
fmt.Printf("%-6d %-30s %-20s %-10s %-6s %s\n", id, email, name, provider, adminStr, createdAt[:19])
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var listAPIKeysCmd = &cobra.Command{
|
||||
Use: "apikeys",
|
||||
Short: "List all API keys",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
withConfig(func(cfg *config.Config, db *database.DB) {
|
||||
var rows *sql.Rows
|
||||
err := db.With(func(conn *sql.DB) error {
|
||||
var err error
|
||||
rows, err = conn.Query("SELECT id, key_prefix, name, scope, is_active, created_at FROM runner_api_keys ORDER BY id")
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
exitWithError("Failed to list API keys: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Printf("%-6s %-12s %-20s %-10s %-8s %s\n", "ID", "Prefix", "Name", "Scope", "Active", "Created")
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var prefix, name, scope string
|
||||
var isActive bool
|
||||
var createdAt string
|
||||
if err := rows.Scan(&id, &prefix, &name, &scope, &isActive, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
activeStr := "no"
|
||||
if isActive {
|
||||
activeStr = "yes"
|
||||
}
|
||||
fmt.Printf("%-6d %-12s %-20s %-10s %-8s %s\n", id, prefix, name, scope, activeStr, createdAt[:19])
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
managerCmd.AddCommand(configCmd)
|
||||
|
||||
// Global config flags
|
||||
configCmd.PersistentFlags().StringVar(&configDBPath, "db", "jiggablend.db", "Database path")
|
||||
configCmd.PersistentFlags().BoolVarP(&configYes, "yes", "y", false, "Auto-confirm prompts")
|
||||
configCmd.PersistentFlags().BoolVarP(&configForce, "force", "f", false, "Force override existing")
|
||||
|
||||
// Enable/Disable
|
||||
configCmd.AddCommand(enableCmd)
|
||||
configCmd.AddCommand(disableCmd)
|
||||
enableCmd.AddCommand(enableLocalAuthCmd)
|
||||
enableCmd.AddCommand(enableRegistrationCmd)
|
||||
enableCmd.AddCommand(enableProductionCmd)
|
||||
disableCmd.AddCommand(disableLocalAuthCmd)
|
||||
disableCmd.AddCommand(disableRegistrationCmd)
|
||||
disableCmd.AddCommand(disableProductionCmd)
|
||||
|
||||
// Add
|
||||
configCmd.AddCommand(addCmd)
|
||||
addCmd.AddCommand(addUserCmd)
|
||||
addUserCmd.Flags().StringVarP(&addUserName, "name", "n", "", "User display name")
|
||||
addUserCmd.Flags().BoolVarP(&addUserAdmin, "admin", "a", false, "Make user an admin")
|
||||
|
||||
addCmd.AddCommand(addAPIKeyCmd)
|
||||
addAPIKeyCmd.Flags().StringVarP(&addAPIKeyScope, "scope", "s", "manager", "API key scope (manager or user)")
|
||||
|
||||
// Set
|
||||
configCmd.AddCommand(setCmd)
|
||||
setCmd.AddCommand(setFixedAPIKeyCmd)
|
||||
setCmd.AddCommand(setAllowedOriginsCmd)
|
||||
setCmd.AddCommand(setGoogleOAuthCmd)
|
||||
setCmd.AddCommand(setDiscordOAuthCmd)
|
||||
|
||||
setGoogleOAuthCmd.Flags().StringVarP(&setGoogleOAuthRedirectURL, "redirect-url", "r", "", "Google OAuth redirect URL")
|
||||
setDiscordOAuthCmd.Flags().StringVarP(&setDiscordOAuthRedirectURL, "redirect-url", "r", "", "Discord OAuth redirect URL")
|
||||
|
||||
// Show
|
||||
configCmd.AddCommand(showCmd)
|
||||
|
||||
// List
|
||||
configCmd.AddCommand(listCmd)
|
||||
listCmd.AddCommand(listUsersCmd)
|
||||
listCmd.AddCommand(listAPIKeysCmd)
|
||||
}
|
||||
|
||||
// withConfig opens the database and runs the callback with config access
|
||||
func withConfig(fn func(cfg *config.Config, db *database.DB)) {
|
||||
db, err := database.NewDB(configDBPath)
|
||||
if err != nil {
|
||||
exitWithError("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cfg := config.NewConfig(db)
|
||||
fn(cfg, db)
|
||||
}
|
||||
|
||||
// generateAPIKey generates a new API key
|
||||
func generateAPIKey() (key, prefix, hash string, err error) {
|
||||
randomBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
randomStr := hex.EncodeToString(randomBytes)
|
||||
|
||||
prefixDigit := make([]byte, 1)
|
||||
if _, err := rand.Read(prefixDigit); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
prefix = fmt.Sprintf("jk_r%d", prefixDigit[0]%10)
|
||||
key = fmt.Sprintf("%s_%s", prefix, randomStr)
|
||||
|
||||
keyHash := sha256.Sum256([]byte(key))
|
||||
hash = hex.EncodeToString(keyHash[:])
|
||||
|
||||
return key, prefix, hash, nil
|
||||
}
|
||||
|
||||
// confirm prompts the user for confirmation
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Printf("%s [y/N]: ", prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
|
||||
35
cmd/jiggablend/cmd/root.go
Normal file
35
cmd/jiggablend/cmd/root.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "jiggablend",
|
||||
Short: "Jiggablend - Distributed Blender Render Farm",
|
||||
Long: `Jiggablend is a distributed render farm for Blender.
|
||||
|
||||
Run 'jiggablend manager' to start the manager server.
|
||||
Run 'jiggablend runner' to start a render runner.
|
||||
Run 'jiggablend manager config' to configure the manager.`,
|
||||
}
|
||||
|
||||
// Execute runs the root command
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Global flags can be added here if needed
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
}
|
||||
|
||||
// exitWithError prints an error and exits
|
||||
func exitWithError(msg string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "Error: "+msg+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
211
cmd/jiggablend/cmd/runner.go
Normal file
211
cmd/jiggablend/cmd/runner.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"jiggablend/internal/logger"
|
||||
"jiggablend/internal/runner"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var runnerViper = viper.New()
|
||||
|
||||
var runnerCmd = &cobra.Command{
|
||||
Use: "runner",
|
||||
Short: "Start the Jiggablend render runner",
|
||||
Long: `Start the Jiggablend render runner that connects to a manager and processes render tasks.`,
|
||||
Run: runRunner,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(runnerCmd)
|
||||
|
||||
runnerCmd.Flags().StringP("manager", "m", "http://localhost:8080", "Manager URL")
|
||||
runnerCmd.Flags().StringP("name", "n", "", "Runner name")
|
||||
runnerCmd.Flags().String("hostname", "", "Runner hostname")
|
||||
runnerCmd.Flags().StringP("api-key", "k", "", "API key for authentication")
|
||||
runnerCmd.Flags().StringP("log-file", "l", "", "Log file path (truncated on start, if not set logs only to stdout)")
|
||||
runnerCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)")
|
||||
runnerCmd.Flags().BoolP("verbose", "v", false, "Enable verbose logging (same as --log-level=debug)")
|
||||
|
||||
// Bind flags to viper with JIGGABLEND_ prefix
|
||||
runnerViper.SetEnvPrefix("JIGGABLEND")
|
||||
runnerViper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
runnerViper.AutomaticEnv()
|
||||
|
||||
runnerViper.BindPFlag("manager", runnerCmd.Flags().Lookup("manager"))
|
||||
runnerViper.BindPFlag("name", runnerCmd.Flags().Lookup("name"))
|
||||
runnerViper.BindPFlag("hostname", runnerCmd.Flags().Lookup("hostname"))
|
||||
runnerViper.BindPFlag("api_key", runnerCmd.Flags().Lookup("api-key"))
|
||||
runnerViper.BindPFlag("log_file", runnerCmd.Flags().Lookup("log-file"))
|
||||
runnerViper.BindPFlag("log_level", runnerCmd.Flags().Lookup("log-level"))
|
||||
runnerViper.BindPFlag("verbose", runnerCmd.Flags().Lookup("verbose"))
|
||||
}
|
||||
|
||||
func runRunner(cmd *cobra.Command, args []string) {
|
||||
// Get config values (flags take precedence over env vars)
|
||||
managerURL := runnerViper.GetString("manager")
|
||||
name := runnerViper.GetString("name")
|
||||
hostname := runnerViper.GetString("hostname")
|
||||
apiKey := runnerViper.GetString("api_key")
|
||||
logFile := runnerViper.GetString("log_file")
|
||||
logLevel := runnerViper.GetString("log_level")
|
||||
verbose := runnerViper.GetBool("verbose")
|
||||
|
||||
var client *runner.Client
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("Runner panicked: %v", r)
|
||||
if client != nil {
|
||||
client.CleanupWorkspace()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
// Generate unique runner ID
|
||||
runnerIDStr := generateShortID()
|
||||
|
||||
// Generate runner name with ID if not provided
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("runner-%s-%s", hostname, runnerIDStr)
|
||||
} else {
|
||||
name = fmt.Sprintf("%s-%s", name, runnerIDStr)
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
if logFile != "" {
|
||||
if err := logger.InitWithFile(logFile); err != nil {
|
||||
logger.Fatalf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if l := logger.GetDefault(); l != nil {
|
||||
l.Close()
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.InitStdout()
|
||||
}
|
||||
|
||||
// Set log level
|
||||
if verbose {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
} else {
|
||||
logger.SetLevel(logger.ParseLevel(logLevel))
|
||||
}
|
||||
|
||||
logger.Info("Runner starting up...")
|
||||
logger.Debugf("Generated runner ID suffix: %s", runnerIDStr)
|
||||
if logFile != "" {
|
||||
logger.Infof("Logging to file: %s", logFile)
|
||||
}
|
||||
|
||||
client = runner.NewClient(managerURL, name, hostname)
|
||||
|
||||
// Clean up orphaned workspace directories
|
||||
client.CleanupWorkspace()
|
||||
|
||||
// Probe capabilities
|
||||
logger.Debug("Probing runner capabilities...")
|
||||
client.ProbeCapabilities()
|
||||
capabilities := client.GetCapabilities()
|
||||
capList := []string{}
|
||||
for cap, value := range capabilities {
|
||||
if enabled, ok := value.(bool); ok && enabled {
|
||||
capList = append(capList, cap)
|
||||
} else if count, ok := value.(int); ok && count > 0 {
|
||||
capList = append(capList, fmt.Sprintf("%s=%d", cap, count))
|
||||
} else if count, ok := value.(float64); ok && count > 0 {
|
||||
capList = append(capList, fmt.Sprintf("%s=%.0f", cap, count))
|
||||
}
|
||||
}
|
||||
if len(capList) > 0 {
|
||||
logger.Infof("Detected capabilities: %s", strings.Join(capList, ", "))
|
||||
} else {
|
||||
logger.Warn("No capabilities detected")
|
||||
}
|
||||
|
||||
// Register with API key
|
||||
if apiKey == "" {
|
||||
logger.Fatal("API key required (use --api-key or set JIGGABLEND_API_KEY env var)")
|
||||
}
|
||||
|
||||
// Retry registration with exponential backoff
|
||||
backoff := 1 * time.Second
|
||||
maxBackoff := 30 * time.Second
|
||||
maxRetries := 10
|
||||
retryCount := 0
|
||||
|
||||
var runnerID int64
|
||||
|
||||
for {
|
||||
var err error
|
||||
runnerID, _, _, err = client.Register(apiKey)
|
||||
if err == nil {
|
||||
logger.Infof("Registered runner with ID: %d", runnerID)
|
||||
break
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "token error:") {
|
||||
logger.Fatalf("Registration failed (token error): %v", err)
|
||||
}
|
||||
|
||||
retryCount++
|
||||
if retryCount >= maxRetries {
|
||||
logger.Fatalf("Failed to register runner after %d attempts: %v", maxRetries, err)
|
||||
}
|
||||
|
||||
logger.Warnf("Registration failed (attempt %d/%d): %v, retrying in %v", retryCount, maxRetries, err, backoff)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
|
||||
// Start WebSocket connection
|
||||
go client.ConnectWebSocketWithReconnect()
|
||||
|
||||
// Start heartbeat loop
|
||||
go client.HeartbeatLoop()
|
||||
|
||||
logger.Info("Runner started, connecting to manager via WebSocket...")
|
||||
|
||||
// Signal handlers
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
logger.Infof("Received signal: %v, killing all processes and cleaning up...", sig)
|
||||
client.KillAllProcesses()
|
||||
client.CleanupWorkspace()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
func generateShortID() string {
|
||||
bytes := make([]byte, 4)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return fmt.Sprintf("%x", os.Getpid()^int(time.Now().Unix()))
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
14
cmd/jiggablend/main.go
Normal file
14
cmd/jiggablend/main.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"jiggablend/cmd/jiggablend/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"jiggablend/internal/api"
|
||||
"jiggablend/internal/auth"
|
||||
"jiggablend/internal/database"
|
||||
"jiggablend/internal/logger"
|
||||
"jiggablend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
port = flag.String("port", getEnv("PORT", "8080"), "Server port")
|
||||
dbPath = flag.String("db", getEnv("DB_PATH", "jiggablend.db"), "Database path")
|
||||
storagePath = flag.String("storage", getEnv("STORAGE_PATH", "./jiggablend-storage"), "Storage path")
|
||||
logDir = flag.String("log-dir", getEnv("LOG_DIR", "./logs"), "Log directory")
|
||||
logMaxSize = flag.Int("log-max-size", getEnvInt("LOG_MAX_SIZE", 100), "Maximum log file size in MB before rotation")
|
||||
logMaxBackups = flag.Int("log-max-backups", getEnvInt("LOG_MAX_BACKUPS", 5), "Maximum number of rotated log files to keep")
|
||||
logMaxAge = flag.Int("log-max-age", getEnvInt("LOG_MAX_AGE", 30), "Maximum age in days for rotated log files")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Initialize logger (writes to both stdout and log file with rotation)
|
||||
logDirPath := *logDir
|
||||
if err := logger.Init(logDirPath, "manager.log", *logMaxSize, *logMaxBackups, *logMaxAge); err != nil {
|
||||
log.Fatalf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if l := logger.GetDefault(); l != nil {
|
||||
l.Close()
|
||||
}
|
||||
}()
|
||||
log.Printf("Log rotation configured: max_size=%dMB, max_backups=%d, max_age=%d days", *logMaxSize, *logMaxBackups, *logMaxAge)
|
||||
|
||||
// Initialize database
|
||||
db, err := database.NewDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize auth
|
||||
authHandler, err := auth.NewAuth(db.DB)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth: %v", err)
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
storageHandler, err := storage.NewStorage(*storagePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
|
||||
// Check if Blender is available (required for metadata extraction)
|
||||
if err := checkBlenderAvailable(); err != nil {
|
||||
log.Fatalf("Blender is not available: %v\n"+
|
||||
"The manager requires Blender to be installed and in PATH for metadata extraction.\n"+
|
||||
"Please install Blender and ensure it's accessible via the 'blender' command.", err)
|
||||
}
|
||||
log.Printf("Blender is available")
|
||||
|
||||
// Create API server
|
||||
server, err := api.NewServer(db, authHandler, storageHandler)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Start server with increased request body size limit for large file uploads
|
||||
addr := fmt.Sprintf(":%s", *port)
|
||||
log.Printf("Starting manager server on %s", addr)
|
||||
log.Printf("Database: %s", *dbPath)
|
||||
log.Printf("Storage: %s", *storagePath)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server,
|
||||
MaxHeaderBytes: 1 << 20, // 1 MB for headers
|
||||
ReadTimeout: 0, // No read timeout (for large uploads)
|
||||
WriteTimeout: 0, // No write timeout (for large uploads)
|
||||
}
|
||||
|
||||
// Note: MaxRequestBodySize is not directly configurable in http.Server
|
||||
// It's handled by ParseMultipartForm in handlers, which we've already configured
|
||||
// But we need to ensure the server can handle large requests
|
||||
// The default limit is 10MB, but we bypass it by using ParseMultipartForm with larger limit
|
||||
|
||||
if err := httpServer.ListenAndServe(); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
var result int
|
||||
if _, err := fmt.Sscanf(value, "%d", &result); err == nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// checkBlenderAvailable checks if Blender is available by running `blender --version`
|
||||
func checkBlenderAvailable() error {
|
||||
cmd := exec.Command("blender", "--version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run 'blender --version': %w (output: %s)", err, string(output))
|
||||
}
|
||||
// If we got here, Blender is available
|
||||
return nil
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"jiggablend/internal/logger"
|
||||
"jiggablend/internal/runner"
|
||||
)
|
||||
|
||||
// Removed SecretsFile - runners now generate ephemeral instance IDs
|
||||
|
||||
func main() {
|
||||
log.Printf("Runner starting up...")
|
||||
|
||||
// Create client early so we can clean it up on panic
|
||||
var client *runner.Client
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Runner panicked: %v", r)
|
||||
// Clean up workspace even on panic
|
||||
if client != nil {
|
||||
client.CleanupWorkspace()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
managerURL = flag.String("manager", getEnv("MANAGER_URL", "http://localhost:8080"), "Manager URL")
|
||||
name = flag.String("name", getEnv("RUNNER_NAME", ""), "Runner name")
|
||||
hostname = flag.String("hostname", getEnv("RUNNER_HOSTNAME", ""), "Runner hostname")
|
||||
apiKeyFlag = flag.String("api-key", getEnv("API_KEY", ""), "API key for authentication")
|
||||
logDir = flag.String("log-dir", getEnv("LOG_DIR", "./logs"), "Log directory")
|
||||
logMaxSize = flag.Int("log-max-size", getEnvInt("LOG_MAX_SIZE", 100), "Maximum log file size in MB before rotation")
|
||||
logMaxBackups = flag.Int("log-max-backups", getEnvInt("LOG_MAX_BACKUPS", 5), "Maximum number of rotated log files to keep")
|
||||
logMaxAge = flag.Int("log-max-age", getEnvInt("LOG_MAX_AGE", 30), "Maximum age in days for rotated log files")
|
||||
)
|
||||
flag.Parse()
|
||||
log.Printf("Flags parsed, hostname: %s", *hostname)
|
||||
|
||||
if *hostname == "" {
|
||||
*hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
// Always generate a random runner ID suffix on startup
|
||||
// This ensures every runner has a unique local identifier
|
||||
runnerIDStr := generateShortID()
|
||||
log.Printf("Generated runner ID suffix: %s", runnerIDStr)
|
||||
|
||||
// Generate runner name with ID if not provided
|
||||
if *name == "" {
|
||||
*name = fmt.Sprintf("runner-%s-%s", *hostname, runnerIDStr)
|
||||
} else {
|
||||
// Append ID to provided name to ensure uniqueness
|
||||
*name = fmt.Sprintf("%s-%s", *name, runnerIDStr)
|
||||
}
|
||||
|
||||
// Initialize logger (writes to both stdout and log file with rotation)
|
||||
// Use runner-specific log file name based on the final name
|
||||
sanitizedName := strings.ReplaceAll(*name, "/", "_")
|
||||
sanitizedName = strings.ReplaceAll(sanitizedName, "\\", "_")
|
||||
logFileName := fmt.Sprintf("runner-%s.log", sanitizedName)
|
||||
|
||||
if err := logger.Init(*logDir, logFileName, *logMaxSize, *logMaxBackups, *logMaxAge); err != nil {
|
||||
log.Fatalf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if l := logger.GetDefault(); l != nil {
|
||||
l.Close()
|
||||
}
|
||||
}()
|
||||
log.Printf("Logger initialized, continuing with startup...")
|
||||
log.Printf("Log rotation configured: max_size=%dMB, max_backups=%d, max_age=%d days", *logMaxSize, *logMaxBackups, *logMaxAge)
|
||||
|
||||
log.Printf("About to create client...")
|
||||
client = runner.NewClient(*managerURL, *name, *hostname)
|
||||
log.Printf("Client created successfully")
|
||||
|
||||
// Clean up any orphaned workspace directories from previous runs
|
||||
client.CleanupWorkspace()
|
||||
|
||||
// Probe capabilities once at startup (before any registration attempts)
|
||||
log.Printf("Probing runner capabilities...")
|
||||
client.ProbeCapabilities()
|
||||
capabilities := client.GetCapabilities()
|
||||
capList := []string{}
|
||||
for cap, value := range capabilities {
|
||||
// Only show boolean true capabilities and numeric GPU counts
|
||||
if enabled, ok := value.(bool); ok && enabled {
|
||||
capList = append(capList, cap)
|
||||
} else if count, ok := value.(int); ok && count > 0 {
|
||||
capList = append(capList, fmt.Sprintf("%s=%d", cap, count))
|
||||
} else if count, ok := value.(float64); ok && count > 0 {
|
||||
capList = append(capList, fmt.Sprintf("%s=%.0f", cap, count))
|
||||
}
|
||||
}
|
||||
if len(capList) > 0 {
|
||||
log.Printf("Detected capabilities: %s", strings.Join(capList, ", "))
|
||||
} else {
|
||||
log.Printf("Warning: No capabilities detected")
|
||||
}
|
||||
|
||||
// Register with API key (with retry logic)
|
||||
if *apiKeyFlag == "" {
|
||||
log.Fatalf("API key required (use --api-key or set API_KEY env var)")
|
||||
}
|
||||
|
||||
// Retry registration with exponential backoff
|
||||
backoff := 1 * time.Second
|
||||
maxBackoff := 30 * time.Second
|
||||
maxRetries := 10
|
||||
retryCount := 0
|
||||
|
||||
var runnerID int64
|
||||
|
||||
for {
|
||||
var err error
|
||||
runnerID, _, _, err = client.Register(*apiKeyFlag)
|
||||
if err == nil {
|
||||
log.Printf("Registered runner with ID: %d", runnerID)
|
||||
break
|
||||
}
|
||||
|
||||
// Check if it's a token error (invalid/expired/used token) - shutdown immediately
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "token error:") {
|
||||
log.Fatalf("Registration failed (token error): %v", err)
|
||||
}
|
||||
|
||||
// Only retry on connection errors or other retryable errors
|
||||
retryCount++
|
||||
if retryCount >= maxRetries {
|
||||
log.Fatalf("Failed to register runner after %d attempts: %v", maxRetries, err)
|
||||
}
|
||||
|
||||
log.Printf("Registration failed (attempt %d/%d): %v, retrying in %v", retryCount, maxRetries, err, backoff)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
|
||||
// Start WebSocket connection with reconnection
|
||||
go client.ConnectWebSocketWithReconnect()
|
||||
|
||||
// Start heartbeat loop (for WebSocket ping/pong and HTTP fallback)
|
||||
go client.HeartbeatLoop()
|
||||
|
||||
// ProcessTasks is now handled via WebSocket, but kept for HTTP fallback
|
||||
// WebSocket will handle task assignment automatically
|
||||
log.Printf("Runner started, connecting to manager via WebSocket...")
|
||||
|
||||
// Set up signal handlers to kill processes on shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
log.Printf("Received signal: %v, killing all processes and cleaning up...", sig)
|
||||
client.KillAllProcesses()
|
||||
// Cleanup happens in defer, but also do it here for good measure
|
||||
client.CleanupWorkspace()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
var result int
|
||||
if _, err := fmt.Sscanf(value, "%d", &result); err == nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// generateShortID generates a short random ID (8 hex characters)
|
||||
func generateShortID() string {
|
||||
bytes := make([]byte, 4)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
// Fallback to timestamp-based ID if crypto/rand fails
|
||||
return fmt.Sprintf("%x", os.Getpid()^int(time.Now().Unix()))
|
||||
}
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
Reference in New Issue
Block a user