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
// 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)

View File

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

View File

@@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"llamactl/pkg/backends"
"llamactl/pkg/instance"
"log"
"time"
@@ -15,25 +14,15 @@ import (
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
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 {
@@ -366,149 +246,49 @@ 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,
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)
}

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ type instanceManager struct {
// Components (each with own synchronization)
registry *instanceRegistry
ports *portAllocator
db database.Database
db database.DB
remote *remoteManager
lifecycle *lifecycleManager
@@ -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 {

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