Refactor database interface and migration functions

This commit is contained in:
2025-11-30 14:21:04 +01:00
parent d5819cf1e4
commit 4c3660477e
7 changed files with 75 additions and 528 deletions

View File

@@ -13,10 +13,7 @@ import (
// migrateFromJSON migrates instances from JSON files to SQLite database // migrateFromJSON migrates instances from JSON files to SQLite database
// This is a one-time migration that runs on first startup with existing JSON files. // This is a one-time migration that runs on first startup with existing JSON files.
// func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
// 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 {
instancesDir := cfg.Instances.InstancesDir instancesDir := cfg.Instances.InstancesDir
if instancesDir == "" { if instancesDir == "" {
return nil // No instances directory configured 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 // 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) data, err := os.ReadFile(filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to read file: %w", err) return fmt.Errorf("failed to read file: %w", err)

View File

@@ -11,8 +11,8 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
// Database defines the interface for instance persistence operations // DB defines the interface for instance persistence operations
type Database interface { type DB interface {
Save(inst *instance.Instance) error Save(inst *instance.Instance) error
Delete(name string) error Delete(name string) error
LoadAll() ([]*instance.Instance, error) LoadAll() ([]*instance.Instance, error)
@@ -30,14 +30,14 @@ type Config struct {
ConnMaxLifetime time.Duration ConnMaxLifetime time.Duration
} }
// DB wraps the database connection with configuration // sqliteDB wraps the database connection with configuration
type DB struct { type sqliteDB struct {
*sql.DB *sql.DB
config *Config config *Config
} }
// Open creates a new database connection with the provided configuration // 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 { if config == nil {
return nil, fmt.Errorf("database config cannot be 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 // Open SQLite database with proper options
// - _journal_mode=WAL: Write-Ahead Logging for better concurrency // - _journal_mode=WAL: Write-Ahead Logging for better concurrency
// - _busy_timeout=5000: Wait up to 5 seconds if database is locked // - _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) dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path)
sqlDB, err := sql.Open("sqlite3", dsn) sqlDB, err := sql.Open("sqlite3", dsn)
@@ -83,14 +83,14 @@ func Open(config *Config) (*DB, error) {
log.Printf("Database connection established: %s", config.Path) log.Printf("Database connection established: %s", config.Path)
return &DB{ return &sqliteDB{
DB: sqlDB, DB: sqlDB,
config: config, config: config,
}, nil }, nil
} }
// Close closes the database connection // Close closes the database connection
func (db *DB) Close() error { func (db *sqliteDB) Close() error {
if db.DB != nil { if db.DB != nil {
log.Println("Closing database connection") log.Println("Closing database connection")
return db.DB.Close() return db.DB.Close()
@@ -99,7 +99,7 @@ func (db *DB) Close() error {
} }
// HealthCheck verifies the database is accessible // HealthCheck verifies the database is accessible
func (db *DB) HealthCheck() error { func (db *sqliteDB) HealthCheck() error {
if db.DB == nil { if db.DB == nil {
return fmt.Errorf("database connection is nil") return fmt.Errorf("database connection is nil")
} }

View File

@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"llamactl/pkg/backends"
"llamactl/pkg/instance" "llamactl/pkg/instance"
"log" "log"
"time" "time"
@@ -15,25 +14,15 @@ import (
type instanceRow struct { type instanceRow struct {
ID int ID int
Name string Name string
BackendType string
BackendConfigJSON string
Status string Status string
CreatedAt int64 CreatedAt int64
UpdatedAt int64 UpdatedAt int64
AutoRestart int OptionsJSON string
MaxRestarts int
RestartDelay int
OnDemandStart int
IdleTimeout int
DockerEnabled int
CommandOverride sql.NullString
Nodes sql.NullString
Environment sql.NullString
OwnerUserID sql.NullString OwnerUserID sql.NullString
} }
// Create inserts a new instance into the database // 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 { if inst == nil {
return fmt.Errorf("instance cannot be 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 // Insert into database
query := ` query := `
INSERT INTO instances ( INSERT INTO instances (
name, backend_type, backend_config_json, status, name, status, created_at, updated_at, options_json, owner_user_id
created_at, updated_at, ) VALUES (?, ?, ?, ?, ?, ?)
auto_restart, max_restarts, restart_delay,
on_demand_start, idle_timeout, docker_enabled,
command_override, nodes, environment, owner_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
_, err = db.DB.ExecContext(ctx, query, _, err = db.DB.ExecContext(ctx, query,
row.Name, row.BackendType, row.BackendConfigJSON, row.Status, row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
row.CreatedAt, row.UpdatedAt,
row.AutoRestart, row.MaxRestarts, row.RestartDelay,
row.OnDemandStart, row.IdleTimeout, row.DockerEnabled,
row.CommandOverride, row.Nodes, row.Environment, row.OwnerUserID,
) )
if err != nil { if err != nil {
@@ -76,24 +57,16 @@ func (db *DB) Create(ctx context.Context, inst *instance.Instance) error {
} }
// GetByName retrieves an instance by name // 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 := ` query := `
SELECT id, name, backend_type, backend_config_json, status, SELECT id, name, status, created_at, updated_at, options_json, owner_user_id
created_at, updated_at,
auto_restart, max_restarts, restart_delay,
on_demand_start, idle_timeout, docker_enabled,
command_override, nodes, environment, owner_user_id
FROM instances FROM instances
WHERE name = ? WHERE name = ?
` `
var row instanceRow var row instanceRow
err := db.DB.QueryRowContext(ctx, query, name).Scan( err := db.DB.QueryRowContext(ctx, query, name).Scan(
&row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID,
&row.CreatedAt, &row.UpdatedAt,
&row.AutoRestart, &row.MaxRestarts, &row.RestartDelay,
&row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled,
&row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID,
) )
if err == sql.ErrNoRows { 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 // 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 := ` query := `
SELECT id, name, backend_type, backend_config_json, status, SELECT id, name, status, created_at, updated_at, options_json, owner_user_id
created_at, updated_at,
auto_restart, max_restarts, restart_delay,
on_demand_start, idle_timeout, docker_enabled,
command_override, nodes, environment, owner_user_id
FROM instances FROM instances
ORDER BY created_at ASC ORDER BY created_at ASC
` `
@@ -128,11 +97,7 @@ func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) {
for rows.Next() { for rows.Next() {
var row instanceRow var row instanceRow
err := rows.Scan( err := rows.Scan(
&row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID,
&row.CreatedAt, &row.UpdatedAt,
&row.AutoRestart, &row.MaxRestarts, &row.RestartDelay,
&row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled,
&row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID,
) )
if err != nil { if err != nil {
log.Printf("Failed to scan instance row: %v", err) 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 // 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 { if inst == nil {
return fmt.Errorf("instance cannot be 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 // Update in database
query := ` query := `
UPDATE instances SET UPDATE instances SET
backend_type = ?, backend_config_json = ?, status = ?, status = ?, updated_at = ?, options_json = ?
updated_at = ?,
auto_restart = ?, max_restarts = ?, restart_delay = ?,
on_demand_start = ?, idle_timeout = ?, docker_enabled = ?,
command_override = ?, nodes = ?, environment = ?
WHERE name = ? WHERE name = ?
` `
result, err := db.DB.ExecContext(ctx, query, result, err := db.DB.ExecContext(ctx, query,
row.BackendType, row.BackendConfigJSON, row.Status, row.Status, row.UpdatedAt, row.OptionsJSON, row.Name,
row.UpdatedAt,
row.AutoRestart, row.MaxRestarts, row.RestartDelay,
row.OnDemandStart, row.IdleTimeout, row.DockerEnabled,
row.CommandOverride, row.Nodes, row.Environment,
row.Name,
) )
if err != nil { 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) // 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 // Convert status to string
statusJSON, err := status.MarshalJSON() statusJSON, err := status.MarshalJSON()
if err != nil { 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 // 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 = ?` query := `DELETE FROM instances WHERE name = ?`
result, err := db.DB.ExecContext(ctx, query, 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 // 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() opts := inst.GetOptions()
if opts == nil { if opts == nil {
return nil, fmt.Errorf("instance options cannot be nil") return nil, fmt.Errorf("instance options cannot be nil")
} }
// Marshal backend options to JSON (this uses the MarshalJSON method which handles typed backends) // Marshal options to JSON using the existing MarshalJSON method
backendJSON, err := json.Marshal(&opts.BackendOptions) optionsJSON, err := json.Marshal(opts)
if err != nil { 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 // Convert status to string
statusJSON, err := inst.GetStatus().MarshalJSON() statusJSON, err := inst.GetStatus().MarshalJSON()
if err != nil { if err != nil {
@@ -366,149 +246,49 @@ func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) {
return &instanceRow{ return &instanceRow{
Name: inst.Name, Name: inst.Name,
BackendType: string(opts.BackendOptions.BackendType),
BackendConfigJSON: string(backendConfigJSON),
Status: statusStr, Status: statusStr,
CreatedAt: inst.Created, CreatedAt: inst.Created,
UpdatedAt: now, UpdatedAt: time.Now().Unix(),
AutoRestart: autoRestart, OptionsJSON: string(optionsJSON),
MaxRestarts: maxRestarts,
RestartDelay: restartDelay,
OnDemandStart: onDemandStart,
IdleTimeout: idleTimeout,
DockerEnabled: dockerEnabled,
CommandOverride: cmdOverride,
Nodes: nodesJSON,
Environment: envJSON,
}, nil }, nil
} }
// rowToInstance converts a database row to an Instance // rowToInstance converts a database row to an Instance
func (db *DB) rowToInstance(row *instanceRow) (*instance.Instance, error) { func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) {
// Unmarshal backend config // Unmarshal options from JSON using the existing UnmarshalJSON method
var backendConfig map[string]any var opts instance.Options
if err := json.Unmarshal([]byte(row.BackendConfigJSON), &backendConfig); err != nil { if err := json.Unmarshal([]byte(row.OptionsJSON), &opts); err != nil {
return nil, fmt.Errorf("failed to unmarshal backend config: %w", err) return nil, fmt.Errorf("failed to unmarshal options: %w", err)
} }
// Create backends.Options by marshaling and unmarshaling to trigger the UnmarshalJSON logic // Build complete instance JSON with all fields
// This ensures the typed backend fields (LlamaServerOptions, VllmServerOptions, etc.) are populated instanceJSON, err := json.Marshal(map[string]any{
var backendOptions backends.Options "name": row.Name,
backendJSON, err := json.Marshal(map[string]any{ "created": row.CreatedAt,
"backend_type": row.BackendType, "status": row.Status,
"backend_options": backendConfig, "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 { if err != nil {
return nil, fmt.Errorf("failed to marshal instance: %w", err) 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) return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
} }
// Manually set the fields that have json:"-" tags by using SetOptions // The UnmarshalJSON doesn't handle BackendOptions and Nodes (they have json:"-" tags)
// We need to set the whole options object because GetOptions returns a copy // So we need to explicitly set the options again to ensure they're properly set
// and we need to ensure BackendOptions and Nodes (which have json:"-") are set inst.SetOptions(&opts)
inst.SetOptions(opts)
return inst, nil return &inst, nil
} }
// Database interface implementation // Database interface implementation
// Save saves an instance to the database (insert or update) // 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() ctx := context.Background()
// Try to get existing instance // Try to get existing instance
@@ -527,13 +307,13 @@ func (db *DB) Save(inst *instance.Instance) error {
} }
// Delete removes an instance from the database // Delete removes an instance from the database
func (db *DB) Delete(name string) error { func (db *sqliteDB) Delete(name string) error {
ctx := context.Background() ctx := context.Background()
return db.DeleteInstance(ctx, name) return db.DeleteInstance(ctx, name)
} }
// LoadAll loads all instances from the database // LoadAll loads all instances from the database
func (db *DB) LoadAll() ([]*instance.Instance, error) { func (db *sqliteDB) LoadAll() ([]*instance.Instance, error) {
ctx := context.Background() ctx := context.Background()
return db.GetAll(ctx) return db.GetAll(ctx)
} }

View File

@@ -14,7 +14,7 @@ import (
var migrationFiles embed.FS var migrationFiles embed.FS
// RunMigrations applies all pending database migrations // RunMigrations applies all pending database migrations
func RunMigrations(db *DB) error { func RunMigrations(db *sqliteDB) error {
if db == nil || db.DB == nil { if db == nil || db.DB == nil {
return fmt.Errorf("database connection is nil") return fmt.Errorf("database connection is nil")
} }

View File

@@ -6,10 +6,6 @@ CREATE TABLE IF NOT EXISTS instances (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, 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 -- Instance state
status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped', 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, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
-- Common instance options (extracted from Instance.Options) -- All instance options stored as a single JSON blob
-- NOT NULL with defaults to match config behavior (nil pointers use these defaults) options_json TEXT NOT NULL,
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)
-- JSON fields for complex structures (nullable - empty when not set) -- Future: OIDC user ID for ownership
nodes TEXT, -- JSON array of node names for remote instances owner_user_id TEXT NULL
environment TEXT, -- JSON map of environment variables
-- Future extensibility hook
owner_user_id TEXT NULL -- Future: OIDC user ID for ownership
);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- Indexes for performance -- Indexes for performance
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); 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_status ON instances(status);
CREATE INDEX IF NOT EXISTS idx_instances_backend_type ON instances(backend_type);

View File

@@ -31,7 +31,7 @@ type instanceManager struct {
// Components (each with own synchronization) // Components (each with own synchronization)
registry *instanceRegistry registry *instanceRegistry
ports *portAllocator ports *portAllocator
db database.Database db database.DB
remote *remoteManager remote *remoteManager
lifecycle *lifecycleManager lifecycle *lifecycleManager
@@ -44,7 +44,7 @@ type instanceManager struct {
} }
// New creates a new instance of InstanceManager with dependency injection. // 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 { if globalConfig.Instances.TimeoutCheckInterval <= 0 {
globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set 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 { if newStatus == instance.Running {
im.registry.markRunning(name) im.registry.markRunning(name)
} else { } else {

213
spec.md
View File

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