mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-22 17:14:22 +00:00
Implement SQLite database persistence for instance management
This commit is contained in:
107
pkg/database/database.go
Normal file
107
pkg/database/database.go
Normal 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
539
pkg/database/instances.go
Normal 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)
|
||||
}
|
||||
78
pkg/database/migrations.go
Normal file
78
pkg/database/migrations.go
Normal 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)
|
||||
}
|
||||
7
pkg/database/migrations/001_initial_schema.down.sql
Normal file
7
pkg/database/migrations/001_initial_schema.down.sql
Normal 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;
|
||||
43
pkg/database/migrations/001_initial_schema.up.sql
Normal file
43
pkg/database/migrations/001_initial_schema.up.sql
Normal 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);
|
||||
Reference in New Issue
Block a user