Implement SQLite database persistence for instance management

This commit is contained in:
2025-11-30 00:12:03 +01:00
parent 0c11365d7e
commit fec989fee2
16 changed files with 1325 additions and 245 deletions

107
pkg/database/database.go Normal file
View File

@@ -0,0 +1,107 @@
package database
import (
"database/sql"
"fmt"
"llamactl/pkg/instance"
"log"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Database defines the interface for instance persistence operations
type Database interface {
Save(inst *instance.Instance) error
Delete(name string) error
LoadAll() ([]*instance.Instance, error)
Close() error
}
// Config contains database configuration settings
type Config struct {
// Database file path (relative to data_dir or absolute)
Path string
// Connection settings
MaxOpenConnections int
MaxIdleConnections int
ConnMaxLifetime time.Duration
}
// DB wraps the database connection with configuration
type DB struct {
*sql.DB
config *Config
}
// Open creates a new database connection with the provided configuration
func Open(config *Config) (*DB, error) {
if config == nil {
return nil, fmt.Errorf("database config cannot be nil")
}
if config.Path == "" {
return nil, fmt.Errorf("database path cannot be empty")
}
// Ensure the database directory exists
dbDir := filepath.Dir(config.Path)
if dbDir != "." && dbDir != "/" {
// Directory will be created by the manager if auto_create_dirs is enabled
log.Printf("Database will be created at: %s", config.Path)
}
// 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)
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path)
sqlDB, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Configure connection pool
if config.MaxOpenConnections > 0 {
sqlDB.SetMaxOpenConns(config.MaxOpenConnections)
}
if config.MaxIdleConnections > 0 {
sqlDB.SetMaxIdleConns(config.MaxIdleConnections)
}
if config.ConnMaxLifetime > 0 {
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
}
// Verify database connection
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
log.Printf("Database connection established: %s", config.Path)
return &DB{
DB: sqlDB,
config: config,
}, nil
}
// Close closes the database connection
func (db *DB) Close() error {
if db.DB != nil {
log.Println("Closing database connection")
return db.DB.Close()
}
return nil
}
// HealthCheck verifies the database is accessible
func (db *DB) HealthCheck() error {
if db.DB == nil {
return fmt.Errorf("database connection is nil")
}
return db.DB.Ping()
}

539
pkg/database/instances.go Normal file
View File

@@ -0,0 +1,539 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"llamactl/pkg/backends"
"llamactl/pkg/instance"
"log"
"time"
)
// 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
}
// Create inserts a new instance into the database
func (db *DB) Create(ctx context.Context, inst *instance.Instance) error {
if inst == nil {
return fmt.Errorf("instance cannot be nil")
}
opts := inst.GetOptions()
if opts == nil {
return fmt.Errorf("instance options cannot be nil")
}
// Convert instance to database row
row, err := db.instanceToRow(inst)
if err != nil {
return fmt.Errorf("failed to convert instance to row: %w", err)
}
// 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, 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,
)
if err != nil {
return fmt.Errorf("failed to insert instance: %w", err)
}
return nil
}
// GetByName retrieves an instance by name
func (db *DB) 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
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,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("instance not found: %s", name)
}
if err != nil {
return nil, fmt.Errorf("failed to query instance: %w", err)
}
return db.rowToInstance(&row)
}
// GetAll retrieves all instances from the database
func (db *DB) 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
FROM instances
ORDER BY created_at ASC
`
rows, err := db.DB.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query instances: %w", err)
}
defer rows.Close()
var instances []*instance.Instance
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,
)
if err != nil {
log.Printf("Failed to scan instance row: %v", err)
continue
}
inst, err := db.rowToInstance(&row)
if err != nil {
log.Printf("Failed to convert row to instance: %v", err)
continue
}
instances = append(instances, inst)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
return instances, nil
}
// Update updates an existing instance
func (db *DB) Update(ctx context.Context, inst *instance.Instance) error {
if inst == nil {
return fmt.Errorf("instance cannot be nil")
}
opts := inst.GetOptions()
if opts == nil {
return fmt.Errorf("instance options cannot be nil")
}
// Convert instance to database row
row, err := db.instanceToRow(inst)
if err != nil {
return fmt.Errorf("failed to convert instance to row: %w", err)
}
// 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 = ?
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,
)
if err != nil {
return fmt.Errorf("failed to update instance: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("instance not found: %s", inst.Name)
}
return nil
}
// UpdateStatus updates only the status of an instance (optimized operation)
func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Status) error {
// Convert status to string
statusJSON, err := status.MarshalJSON()
if err != nil {
return fmt.Errorf("failed to marshal status: %w", err)
}
var statusStr string
if err := json.Unmarshal(statusJSON, &statusStr); err != nil {
return fmt.Errorf("failed to unmarshal status string: %w", err)
}
query := `
UPDATE instances SET
status = ?,
updated_at = ?
WHERE name = ?
`
result, err := db.DB.ExecContext(ctx, query, statusStr, time.Now().Unix(), name)
if err != nil {
return fmt.Errorf("failed to update instance status: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("instance not found: %s", name)
}
return nil
}
// DeleteInstance removes an instance from the database
func (db *DB) DeleteInstance(ctx context.Context, name string) error {
query := `DELETE FROM instances WHERE name = ?`
result, err := db.DB.ExecContext(ctx, query, name)
if err != nil {
return fmt.Errorf("failed to delete instance: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("instance not found: %s", name)
}
return nil
}
// instanceToRow converts an Instance to a database row
func (db *DB) 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)
if err != nil {
return nil, fmt.Errorf("failed to marshal backend 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 {
return nil, fmt.Errorf("failed to marshal status: %w", err)
}
var statusStr string
if err := json.Unmarshal(statusJSON, &statusStr); err != nil {
return nil, fmt.Errorf("failed to unmarshal status string: %w", err)
}
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,
}, 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)
}
// 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,
})
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 {
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)
return inst, nil
}
// Database interface implementation
// Save saves an instance to the database (insert or update)
func (db *DB) Save(inst *instance.Instance) error {
ctx := context.Background()
// Try to get existing instance
existing, err := db.GetByName(ctx, inst.Name)
if err != nil {
// Instance doesn't exist, create it
return db.Create(ctx, inst)
}
// Instance exists, update it
if existing != nil {
return db.Update(ctx, inst)
}
return db.Create(ctx, inst)
}
// Delete removes an instance from the database
func (db *DB) 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) {
ctx := context.Background()
return db.GetAll(ctx)
}

View File

@@ -0,0 +1,78 @@
package database
import (
"embed"
"fmt"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
// RunMigrations applies all pending database migrations
func RunMigrations(db *DB) error {
if db == nil || db.DB == nil {
return fmt.Errorf("database connection is nil")
}
// Create migration source from embedded files
sourceDriver, err := iofs.New(migrationFiles, "migrations")
if err != nil {
return fmt.Errorf("failed to create migration source: %w", err)
}
// Create database driver for migrations
dbDriver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("failed to create database driver: %w", err)
}
// Create migrator
migrator, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", dbDriver)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
}
// Get current version
currentVersion, dirty, err := migrator.Version()
if err != nil && err != migrate.ErrNilVersion {
return fmt.Errorf("failed to get current migration version: %w", err)
}
if dirty {
return fmt.Errorf("database is in dirty state at version %d - manual intervention required", currentVersion)
}
// Run migrations
log.Printf("Running database migrations (current version: %v)", currentVersionString(currentVersion, err))
if err := migrator.Up(); err != nil {
if err == migrate.ErrNoChange {
log.Println("Database schema is up to date")
return nil
}
return fmt.Errorf("failed to run migrations: %w", err)
}
// Get new version
newVersion, _, err := migrator.Version()
if err != nil {
log.Printf("Migrations completed (unable to determine new version: %v)", err)
} else {
log.Printf("Migrations completed successfully (new version: %d)", newVersion)
}
return nil
}
// currentVersionString returns a string representation of the current version
func currentVersionString(version uint, err error) string {
if err == migrate.ErrNilVersion {
return "none"
}
return fmt.Sprintf("%d", version)
}

View File

@@ -0,0 +1,7 @@
-- Drop indexes first
DROP INDEX IF EXISTS idx_instances_backend_type;
DROP INDEX IF EXISTS idx_instances_status;
DROP INDEX IF EXISTS idx_instances_name;
-- Drop tables
DROP TABLE IF EXISTS instances;

View File

@@ -0,0 +1,43 @@
-- -----------------------------------------------------------------------------
-- Instances Table: Central configuration and state for LLM backends
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS instances (
-- Primary identification
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',
-- Timestamps (created_at stored as Unix timestamp for compatibility with existing JSON format)
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)
-- 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
);
-- -----------------------------------------------------------------------------
-- 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);