mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-12-22 17:14:22 +00:00
Refactor database interface and migration functions
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -13,27 +12,17 @@ import (
|
|||||||
|
|
||||||
// instanceRow represents a row in the instances table
|
// instanceRow represents a row in the instances table
|
||||||
type instanceRow struct {
|
type instanceRow struct {
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
BackendType string
|
Status string
|
||||||
BackendConfigJSON string
|
CreatedAt int64
|
||||||
Status string
|
UpdatedAt int64
|
||||||
CreatedAt int64
|
OptionsJSON string
|
||||||
UpdatedAt int64
|
OwnerUserID sql.NullString
|
||||||
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
|
// 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 {
|
||||||
@@ -365,150 +245,50 @@ func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &instanceRow{
|
return &instanceRow{
|
||||||
Name: inst.Name,
|
Name: inst.Name,
|
||||||
BackendType: string(opts.BackendOptions.BackendType),
|
Status: statusStr,
|
||||||
BackendConfigJSON: string(backendConfigJSON),
|
CreatedAt: inst.Created,
|
||||||
Status: statusStr,
|
UpdatedAt: time.Now().Unix(),
|
||||||
CreatedAt: inst.Created,
|
OptionsJSON: string(optionsJSON),
|
||||||
UpdatedAt: now,
|
|
||||||
AutoRestart: autoRestart,
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ type InstanceManager interface {
|
|||||||
|
|
||||||
type instanceManager struct {
|
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
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
globalConfig *config.AppConfig
|
globalConfig *config.AppConfig
|
||||||
@@ -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
213
spec.md
@@ -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
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user