diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go index 82a02ac..7ee6a2b 100644 --- a/cmd/server/migrate_json.go +++ b/cmd/server/migrate_json.go @@ -13,10 +13,7 @@ import ( // migrateFromJSON migrates instances from JSON files to SQLite database // This is a one-time migration that runs on first startup with existing JSON files. -// -// TODO: This migration code can be removed in a future version (post-1.0) -// once most users have migrated from JSON to SQLite. -func migrateFromJSON(cfg *config.AppConfig, db database.Database) error { +func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { instancesDir := cfg.Instances.InstancesDir if instancesDir == "" { return nil // No instances directory configured @@ -79,7 +76,7 @@ func migrateFromJSON(cfg *config.AppConfig, db database.Database) error { } // migrateJSONFile migrates a single JSON file to the database -func migrateJSONFile(filename string, db database.Database) error { +func migrateJSONFile(filename string, db database.DB) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read file: %w", err) diff --git a/pkg/database/database.go b/pkg/database/database.go index 516425c..957fac7 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -11,8 +11,8 @@ import ( _ "github.com/mattn/go-sqlite3" ) -// Database defines the interface for instance persistence operations -type Database interface { +// DB defines the interface for instance persistence operations +type DB interface { Save(inst *instance.Instance) error Delete(name string) error LoadAll() ([]*instance.Instance, error) @@ -30,14 +30,14 @@ type Config struct { ConnMaxLifetime time.Duration } -// DB wraps the database connection with configuration -type DB struct { +// sqliteDB wraps the database connection with configuration +type sqliteDB struct { *sql.DB config *Config } // Open creates a new database connection with the provided configuration -func Open(config *Config) (*DB, error) { +func Open(config *Config) (*sqliteDB, error) { if config == nil { return nil, fmt.Errorf("database config cannot be nil") } @@ -56,7 +56,7 @@ func Open(config *Config) (*DB, error) { // Open SQLite database with proper options // - _journal_mode=WAL: Write-Ahead Logging for better concurrency // - _busy_timeout=5000: Wait up to 5 seconds if database is locked - // - _foreign_keys=1: Enable foreign key constraints (for future use) + // - _foreign_keys=1: Enable foreign key constraints dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path) sqlDB, err := sql.Open("sqlite3", dsn) @@ -83,14 +83,14 @@ func Open(config *Config) (*DB, error) { log.Printf("Database connection established: %s", config.Path) - return &DB{ + return &sqliteDB{ DB: sqlDB, config: config, }, nil } // Close closes the database connection -func (db *DB) Close() error { +func (db *sqliteDB) Close() error { if db.DB != nil { log.Println("Closing database connection") return db.DB.Close() @@ -99,7 +99,7 @@ func (db *DB) Close() error { } // HealthCheck verifies the database is accessible -func (db *DB) HealthCheck() error { +func (db *sqliteDB) HealthCheck() error { if db.DB == nil { return fmt.Errorf("database connection is nil") } diff --git a/pkg/database/instances.go b/pkg/database/instances.go index 721ea96..1dbdb32 100644 --- a/pkg/database/instances.go +++ b/pkg/database/instances.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "llamactl/pkg/backends" "llamactl/pkg/instance" "log" "time" @@ -13,27 +12,17 @@ import ( // instanceRow represents a row in the instances table type instanceRow struct { - ID int - Name string - BackendType string - BackendConfigJSON string - Status string - CreatedAt int64 - UpdatedAt int64 - AutoRestart int - MaxRestarts int - RestartDelay int - OnDemandStart int - IdleTimeout int - DockerEnabled int - CommandOverride sql.NullString - Nodes sql.NullString - Environment sql.NullString - OwnerUserID sql.NullString + ID int + Name string + Status string + CreatedAt int64 + UpdatedAt int64 + OptionsJSON string + OwnerUserID sql.NullString } // Create inserts a new instance into the database -func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { +func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { if inst == nil { return fmt.Errorf("instance cannot be nil") } @@ -52,20 +41,12 @@ func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { // Insert into database query := ` INSERT INTO instances ( - name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + name, status, created_at, updated_at, options_json, owner_user_id + ) VALUES (?, ?, ?, ?, ?, ?) ` _, err = db.DB.ExecContext(ctx, query, - row.Name, row.BackendType, row.BackendConfigJSON, row.Status, - row.CreatedAt, row.UpdatedAt, - row.AutoRestart, row.MaxRestarts, row.RestartDelay, - row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, - row.CommandOverride, row.Nodes, row.Environment, row.OwnerUserID, + row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID, ) if err != nil { @@ -76,24 +57,16 @@ func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { } // GetByName retrieves an instance by name -func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, error) { +func (db *sqliteDB) GetByName(ctx context.Context, name string) (*instance.Instance, error) { query := ` - SELECT id, name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id + SELECT id, name, status, created_at, updated_at, options_json, owner_user_id FROM instances WHERE name = ? ` var row instanceRow err := db.DB.QueryRowContext(ctx, query, name).Scan( - &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, - &row.CreatedAt, &row.UpdatedAt, - &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, - &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, - &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID, ) if err == sql.ErrNoRows { @@ -107,13 +80,9 @@ func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, e } // GetAll retrieves all instances from the database -func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { +func (db *sqliteDB) GetAll(ctx context.Context) ([]*instance.Instance, error) { query := ` - SELECT id, name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id + SELECT id, name, status, created_at, updated_at, options_json, owner_user_id FROM instances ORDER BY created_at ASC ` @@ -128,11 +97,7 @@ func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { for rows.Next() { var row instanceRow err := rows.Scan( - &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, - &row.CreatedAt, &row.UpdatedAt, - &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, - &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, - &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID, ) if err != nil { log.Printf("Failed to scan instance row: %v", err) @@ -156,7 +121,7 @@ func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { } // Update updates an existing instance -func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { +func (db *sqliteDB) Update(ctx context.Context, inst *instance.Instance) error { if inst == nil { return fmt.Errorf("instance cannot be nil") } @@ -175,21 +140,12 @@ func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { // Update in database query := ` UPDATE instances SET - backend_type = ?, backend_config_json = ?, status = ?, - updated_at = ?, - auto_restart = ?, max_restarts = ?, restart_delay = ?, - on_demand_start = ?, idle_timeout = ?, docker_enabled = ?, - command_override = ?, nodes = ?, environment = ? + status = ?, updated_at = ?, options_json = ? WHERE name = ? ` result, err := db.DB.ExecContext(ctx, query, - row.BackendType, row.BackendConfigJSON, row.Status, - row.UpdatedAt, - row.AutoRestart, row.MaxRestarts, row.RestartDelay, - row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, - row.CommandOverride, row.Nodes, row.Environment, - row.Name, + row.Status, row.UpdatedAt, row.OptionsJSON, row.Name, ) if err != nil { @@ -209,7 +165,7 @@ func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { } // UpdateStatus updates only the status of an instance (optimized operation) -func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Status) error { +func (db *sqliteDB) UpdateStatus(ctx context.Context, name string, status instance.Status) error { // Convert status to string statusJSON, err := status.MarshalJSON() if err != nil { @@ -245,7 +201,7 @@ func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Sta } // DeleteInstance removes an instance from the database -func (db *DB) DeleteInstance(ctx context.Context, name string) error { +func (db *sqliteDB) DeleteInstance(ctx context.Context, name string) error { query := `DELETE FROM instances WHERE name = ?` result, err := db.DB.ExecContext(ctx, query, name) @@ -266,94 +222,18 @@ func (db *DB) DeleteInstance(ctx context.Context, name string) error { } // instanceToRow converts an Instance to a database row -func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { +func (db *sqliteDB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { opts := inst.GetOptions() if opts == nil { return nil, fmt.Errorf("instance options cannot be nil") } - // Marshal backend options to JSON (this uses the MarshalJSON method which handles typed backends) - backendJSON, err := json.Marshal(&opts.BackendOptions) + // Marshal options to JSON using the existing MarshalJSON method + optionsJSON, err := json.Marshal(opts) if err != nil { - return nil, fmt.Errorf("failed to marshal backend options: %w", err) + return nil, fmt.Errorf("failed to marshal options: %w", err) } - // Extract just the backend_options field from the marshaled JSON - var backendWrapper struct { - BackendOptions map[string]any `json:"backend_options"` - } - if err := json.Unmarshal(backendJSON, &backendWrapper); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend wrapper: %w", err) - } - - backendConfigJSON, err := json.Marshal(backendWrapper.BackendOptions) - if err != nil { - return nil, fmt.Errorf("failed to marshal backend config: %w", err) - } - - // Convert nodes map to JSON array - var nodesJSON sql.NullString - if len(opts.Nodes) > 0 { - nodesList := make([]string, 0, len(opts.Nodes)) - for node := range opts.Nodes { - nodesList = append(nodesList, node) - } - nodesBytes, err := json.Marshal(nodesList) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodes: %w", err) - } - nodesJSON = sql.NullString{String: string(nodesBytes), Valid: true} - } - - // Convert environment map to JSON - var envJSON sql.NullString - if len(opts.Environment) > 0 { - envBytes, err := json.Marshal(opts.Environment) - if err != nil { - return nil, fmt.Errorf("failed to marshal environment: %w", err) - } - envJSON = sql.NullString{String: string(envBytes), Valid: true} - } - - // Convert command override - var cmdOverride sql.NullString - if opts.CommandOverride != "" { - cmdOverride = sql.NullString{String: opts.CommandOverride, Valid: true} - } - - // Convert boolean pointers to integers (0 or 1) - autoRestart := 0 - if opts.AutoRestart != nil && *opts.AutoRestart { - autoRestart = 1 - } - - maxRestarts := -1 - if opts.MaxRestarts != nil { - maxRestarts = *opts.MaxRestarts - } - - restartDelay := 0 - if opts.RestartDelay != nil { - restartDelay = *opts.RestartDelay - } - - onDemandStart := 0 - if opts.OnDemandStart != nil && *opts.OnDemandStart { - onDemandStart = 1 - } - - idleTimeout := 0 - if opts.IdleTimeout != nil { - idleTimeout = *opts.IdleTimeout - } - - dockerEnabled := 0 - if opts.DockerEnabled != nil && *opts.DockerEnabled { - dockerEnabled = 1 - } - - now := time.Now().Unix() - // Convert status to string statusJSON, err := inst.GetStatus().MarshalJSON() if err != nil { @@ -365,150 +245,50 @@ func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { } return &instanceRow{ - Name: inst.Name, - BackendType: string(opts.BackendOptions.BackendType), - BackendConfigJSON: string(backendConfigJSON), - Status: statusStr, - CreatedAt: inst.Created, - UpdatedAt: now, - AutoRestart: autoRestart, - MaxRestarts: maxRestarts, - RestartDelay: restartDelay, - OnDemandStart: onDemandStart, - IdleTimeout: idleTimeout, - DockerEnabled: dockerEnabled, - CommandOverride: cmdOverride, - Nodes: nodesJSON, - Environment: envJSON, + Name: inst.Name, + Status: statusStr, + CreatedAt: inst.Created, + UpdatedAt: time.Now().Unix(), + OptionsJSON: string(optionsJSON), }, nil } // rowToInstance converts a database row to an Instance -func (db *DB) rowToInstance(row *instanceRow) (*instance.Instance, error) { - // Unmarshal backend config - var backendConfig map[string]any - if err := json.Unmarshal([]byte(row.BackendConfigJSON), &backendConfig); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend config: %w", err) +func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) { + // Unmarshal options from JSON using the existing UnmarshalJSON method + var opts instance.Options + if err := json.Unmarshal([]byte(row.OptionsJSON), &opts); err != nil { + return nil, fmt.Errorf("failed to unmarshal options: %w", err) } - // Create backends.Options by marshaling and unmarshaling to trigger the UnmarshalJSON logic - // This ensures the typed backend fields (LlamaServerOptions, VllmServerOptions, etc.) are populated - var backendOptions backends.Options - backendJSON, err := json.Marshal(map[string]any{ - "backend_type": row.BackendType, - "backend_options": backendConfig, + // Build complete instance JSON with all fields + instanceJSON, err := json.Marshal(map[string]any{ + "name": row.Name, + "created": row.CreatedAt, + "status": row.Status, + "options": json.RawMessage(row.OptionsJSON), }) - if err != nil { - return nil, fmt.Errorf("failed to marshal backend for unmarshaling: %w", err) - } - - if err := json.Unmarshal(backendJSON, &backendOptions); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend options: %w", err) - } - - // Unmarshal nodes - var nodes map[string]struct{} - if row.Nodes.Valid && row.Nodes.String != "" { - var nodesList []string - if err := json.Unmarshal([]byte(row.Nodes.String), &nodesList); err != nil { - return nil, fmt.Errorf("failed to unmarshal nodes: %w", err) - } - nodes = make(map[string]struct{}, len(nodesList)) - for _, node := range nodesList { - nodes[node] = struct{}{} - } - } - - // Unmarshal environment - var environment map[string]string - if row.Environment.Valid && row.Environment.String != "" { - if err := json.Unmarshal([]byte(row.Environment.String), &environment); err != nil { - return nil, fmt.Errorf("failed to unmarshal environment: %w", err) - } - } - - // Convert integers to boolean pointers - autoRestart := row.AutoRestart == 1 - maxRestarts := row.MaxRestarts - restartDelay := row.RestartDelay - onDemandStart := row.OnDemandStart == 1 - idleTimeout := row.IdleTimeout - dockerEnabled := row.DockerEnabled == 1 - - // Create instance options - opts := &instance.Options{ - AutoRestart: &autoRestart, - MaxRestarts: &maxRestarts, - RestartDelay: &restartDelay, - OnDemandStart: &onDemandStart, - IdleTimeout: &idleTimeout, - DockerEnabled: &dockerEnabled, - CommandOverride: row.CommandOverride.String, - Nodes: nodes, - Environment: environment, - BackendOptions: backendOptions, - } - - // Create instance struct and manually unmarshal fields - // We do this manually because BackendOptions and Nodes have json:"-" tags - // and would be lost if we used the marshal/unmarshal cycle - inst := &instance.Instance{ - Name: row.Name, - Created: row.CreatedAt, - } - - // Create a temporary struct for unmarshaling the status and simple fields - type instanceAux struct { - Name string `json:"name"` - Created int64 `json:"created"` - Status string `json:"status"` - Options struct { - AutoRestart *bool `json:"auto_restart,omitempty"` - MaxRestarts *int `json:"max_restarts,omitempty"` - RestartDelay *int `json:"restart_delay,omitempty"` - OnDemandStart *bool `json:"on_demand_start,omitempty"` - IdleTimeout *int `json:"idle_timeout,omitempty"` - DockerEnabled *bool `json:"docker_enabled,omitempty"` - CommandOverride string `json:"command_override,omitempty"` - Environment map[string]string `json:"environment,omitempty"` - } `json:"options"` - } - - aux := instanceAux{ - Name: row.Name, - Created: row.CreatedAt, - Status: row.Status, - } - aux.Options.AutoRestart = opts.AutoRestart - aux.Options.MaxRestarts = opts.MaxRestarts - aux.Options.RestartDelay = opts.RestartDelay - aux.Options.OnDemandStart = opts.OnDemandStart - aux.Options.IdleTimeout = opts.IdleTimeout - aux.Options.DockerEnabled = opts.DockerEnabled - aux.Options.CommandOverride = opts.CommandOverride - aux.Options.Environment = opts.Environment - - instJSON, err := json.Marshal(aux) if err != nil { return nil, fmt.Errorf("failed to marshal instance: %w", err) } - if err := json.Unmarshal(instJSON, inst); err != nil { + // Unmarshal into a complete Instance + var inst instance.Instance + if err := json.Unmarshal(instanceJSON, &inst); err != nil { return nil, fmt.Errorf("failed to unmarshal instance: %w", err) } - // Manually set the fields that have json:"-" tags by using SetOptions - // We need to set the whole options object because GetOptions returns a copy - // and we need to ensure BackendOptions and Nodes (which have json:"-") are set - inst.SetOptions(opts) + // The UnmarshalJSON doesn't handle BackendOptions and Nodes (they have json:"-" tags) + // So we need to explicitly set the options again to ensure they're properly set + inst.SetOptions(&opts) - return inst, nil + return &inst, nil } // Database interface implementation // Save saves an instance to the database (insert or update) -func (db *DB) Save(inst *instance.Instance) error { +func (db *sqliteDB) Save(inst *instance.Instance) error { ctx := context.Background() // Try to get existing instance @@ -527,13 +307,13 @@ func (db *DB) Save(inst *instance.Instance) error { } // Delete removes an instance from the database -func (db *DB) Delete(name string) error { +func (db *sqliteDB) Delete(name string) error { ctx := context.Background() return db.DeleteInstance(ctx, name) } // LoadAll loads all instances from the database -func (db *DB) LoadAll() ([]*instance.Instance, error) { +func (db *sqliteDB) LoadAll() ([]*instance.Instance, error) { ctx := context.Background() return db.GetAll(ctx) } diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index 476d3a8..34fb1bb 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -14,7 +14,7 @@ import ( var migrationFiles embed.FS // RunMigrations applies all pending database migrations -func RunMigrations(db *DB) error { +func RunMigrations(db *sqliteDB) error { if db == nil || db.DB == nil { return fmt.Errorf("database connection is nil") } diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 46e1f06..ddb229c 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -6,10 +6,6 @@ CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - -- Backend configuration - backend_type TEXT NOT NULL CHECK(backend_type IN ('llama_cpp', 'mlx_lm', 'vllm')), - backend_config_json TEXT NOT NULL, -- Backend-specific options (150+ fields for llama_cpp, etc.) - -- Instance state status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped', @@ -17,27 +13,14 @@ CREATE TABLE IF NOT EXISTS instances ( created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - -- Common instance options (extracted from Instance.Options) - -- NOT NULL with defaults to match config behavior (nil pointers use these defaults) - auto_restart INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable automatic restart on failure - max_restarts INTEGER NOT NULL DEFAULT -1, -- Maximum restart attempts (-1 = unlimited) - restart_delay INTEGER NOT NULL DEFAULT 0, -- Delay between restarts in seconds - on_demand_start INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable on-demand instance start - idle_timeout INTEGER NOT NULL DEFAULT 0, -- Idle timeout in minutes before auto-stop - docker_enabled INTEGER NOT NULL DEFAULT 0, -- Boolean: Run instance in Docker container - command_override TEXT, -- Custom command to override default backend command (nullable) + -- All instance options stored as a single JSON blob + options_json TEXT NOT NULL, - -- JSON fields for complex structures (nullable - empty when not set) - nodes TEXT, -- JSON array of node names for remote instances - environment TEXT, -- JSON map of environment variables - - -- Future extensibility hook - owner_user_id TEXT NULL -- Future: OIDC user ID for ownership -); + -- Future: OIDC user ID for ownership + owner_user_id TEXT NULL -- ----------------------------------------------------------------------------- -- Indexes for performance -- ----------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); -CREATE INDEX IF NOT EXISTS idx_instances_backend_type ON instances(backend_type); diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 2bc5bc8..5aca037 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -29,11 +29,11 @@ type InstanceManager interface { type instanceManager struct { // Components (each with own synchronization) - registry *instanceRegistry - ports *portAllocator - db database.Database - remote *remoteManager - lifecycle *lifecycleManager + registry *instanceRegistry + ports *portAllocator + db database.DB + remote *remoteManager + lifecycle *lifecycleManager // Configuration globalConfig *config.AppConfig @@ -44,7 +44,7 @@ type instanceManager struct { } // New creates a new instance of InstanceManager with dependency injection. -func New(globalConfig *config.AppConfig, db database.Database) InstanceManager { +func New(globalConfig *config.AppConfig, db database.DB) InstanceManager { if globalConfig.Instances.TimeoutCheckInterval <= 0 { globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set @@ -259,7 +259,7 @@ func (im *instanceManager) autoStartInstances() { } } -func (im *instanceManager) onStatusChange(name string, oldStatus, newStatus instance.Status) { +func (im *instanceManager) onStatusChange(name string, _, newStatus instance.Status) { if newStatus == instance.Running { im.registry.markRunning(name) } else { diff --git a/spec.md b/spec.md deleted file mode 100644 index 2d27505..0000000 --- a/spec.md +++ /dev/null @@ -1,213 +0,0 @@ -# SQLite Database Persistence - -This document describes the SQLite database persistence implementation for llamactl. - -## Overview - -Llamactl uses SQLite3 for persisting instance configurations and state. The database provides: -- Reliable instance persistence across restarts -- Automatic migration from legacy JSON files -- Prepared for future multi-user features - -## Database Schema - -### `instances` Table - -Stores all instance configurations and state. - -```sql -CREATE TABLE IF NOT EXISTS instances ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - - backend_type TEXT NOT NULL CHECK(backend_type IN ('llama_cpp', 'mlx_lm', 'vllm')), - backend_config_json TEXT NOT NULL, - - status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped', - - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - - auto_restart INTEGER NOT NULL DEFAULT 0, - max_restarts INTEGER NOT NULL DEFAULT -1, - restart_delay INTEGER NOT NULL DEFAULT 0, - on_demand_start INTEGER NOT NULL DEFAULT 0, - idle_timeout INTEGER NOT NULL DEFAULT 0, - docker_enabled INTEGER NOT NULL DEFAULT 0, - command_override TEXT, - - nodes TEXT, - environment TEXT, - - owner_user_id TEXT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); -CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); -CREATE INDEX IF NOT EXISTS idx_instances_backend_type ON instances(backend_type); -``` - -## Architecture - -### Database Layer (`pkg/database`) - -The `database.DB` type implements the `Database` interface: - -```go -// Database interface defines persistence operations -type Database interface { - Save(inst *instance.Instance) error - Delete(name string) error - LoadAll() ([]*instance.Instance, error) - Close() error -} - -type DB struct { - *sql.DB - config *Config -} - -// Database interface methods -func (db *DB) Save(inst *instance.Instance) error -func (db *DB) Delete(name string) error -func (db *DB) LoadAll() ([]*instance.Instance, error) -func (db *DB) Close() error - -// Internal CRUD methods -func (db *DB) Create(ctx context.Context, inst *instance.Instance) error -func (db *DB) Update(ctx context.Context, inst *instance.Instance) error -func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, error) -func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) -func (db *DB) DeleteInstance(ctx context.Context, name string) error -``` - -**Key points:** -- No repository pattern - DB directly implements persistence -- Simple, direct architecture with minimal layers -- Helper methods for row conversion are private to database package - -### Manager Integration - -Manager accepts a `Database` via dependency injection: - -```go -func New(globalConfig *config.AppConfig, db database.Database) InstanceManager -``` - -Main creates the database, runs migrations, and injects it: - -```go -// Initialize database -db, err := database.Open(&database.Config{ - Path: cfg.Database.Path, - MaxOpenConnections: cfg.Database.MaxOpenConnections, - MaxIdleConnections: cfg.Database.MaxIdleConnections, - ConnMaxLifetime: cfg.Database.ConnMaxLifetime, -}) - -// Run database migrations -if err := database.RunMigrations(db); err != nil { - log.Fatalf("Failed to run database migrations: %v", err) -} - -// Migrate from JSON files if needed (one-time migration) -if err := migrateFromJSON(&cfg, db); err != nil { - log.Printf("Warning: Failed to migrate from JSON: %v", err) -} - -instanceManager := manager.New(&cfg, db) -``` - -### JSON Migration (`cmd/server/migrate_json.go`) - -One-time migration utility that runs in main: - -```go -func migrateFromJSON(cfg *config.AppConfig, db database.Database) error -``` - -**Note:** This migration code is temporary and can be removed in a future version (post-1.0) once most users have migrated from JSON to SQLite. - -Handles: -- Automatic one-time JSON→SQLite migration -- Archiving old JSON files after migration - -## Configuration - -```yaml -database: - path: "llamactl.db" # Relative to data_dir or absolute - max_open_connections: 25 - max_idle_connections: 5 - connection_max_lifetime: "5m" -``` - -Environment variables: -- `LLAMACTL_DATABASE_PATH` -- `LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS` -- `LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS` -- `LLAMACTL_DATABASE_CONN_MAX_LIFETIME` - -## JSON Migration - -On first startup with existing JSON files: - -1. Database is created with schema -2. All JSON files are loaded and migrated to database -3. Original JSON files are moved to `{instances_dir}/json_archive/` -4. Subsequent startups use only the database - -**Error Handling:** -- Failed migrations block application startup -- Original JSON files are preserved for rollback -- No fallback to JSON after migration - -## Data Mapping - -**Direct mappings:** -- `Instance.Name` → `instances.name` -- `Instance.Created` → `instances.created_at` (Unix timestamp) -- `Instance.Status` → `instances.status` - -**Backend configuration:** -- `BackendOptions.BackendType` → `instances.backend_type` -- Typed backend options (LlamaServerOptions, etc.) → `instances.backend_config_json` (marshaled via MarshalJSON) - -**Common options:** -- Boolean pointers (`*bool`) → INTEGER (0/1) -- Integer pointers (`*int`) → INTEGER -- `nil` values use column DEFAULT values -- `Nodes` map → `instances.nodes` (JSON array) -- `Environment` map → `instances.environment` (JSON object) - -## Migrations - -Uses `golang-migrate/migrate/v4` with embedded SQL files: - -``` -pkg/database/ -├── database.go # Database interface and DB type -├── migrations.go # Migration runner -├── instances.go # Instance CRUD operations -└── migrations/ - ├── 001_initial_schema.up.sql - └── 001_initial_schema.down.sql - -cmd/server/ -└── migrate_json.go # Temporary JSON→SQLite migration (can be removed post-1.0) -``` - -Migration files are embedded at compile time using `go:embed`. - -## Testing - -Tests use in-memory SQLite databases (`:memory:`) for speed, except when testing persistence across connections. - -```go -appConfig.Database.Path = ":memory:" // Fast in-memory database -``` - -For cross-connection persistence tests: -```go -appConfig.Database.Path = tempDir + "/test.db" // File-based -```