something

This commit is contained in:
2025-11-27 00:46:48 -06:00
parent 11e7552b5b
commit edc8ea160c
43 changed files with 9990 additions and 3059 deletions

View 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
}

View 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"
}

View 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)
}

View 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
View File

@@ -0,0 +1,14 @@
package main
import (
"os"
"jiggablend/cmd/jiggablend/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@@ -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
}

View File

@@ -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)
}