7 Commits

19 changed files with 1007 additions and 320 deletions

2
.vscode/launch.json vendored
View File

@@ -9,7 +9,7 @@
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server/main.go",
"program": "${workspaceFolder}/cmd/server",
"env": {
"GO_ENV": "development",
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"

View File

@@ -179,11 +179,12 @@ backends:
args: []
environment: {} # Environment variables for the backend process
data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS
instances:
port_range: [8000, 9000] # Port range for instances
data_dir: ~/.local/share/llamactl # Data directory (platform-specific, see below)
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory
logs_dir: ~/.local/share/llamactl/logs # Logs directory
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory (platform dependent)
logs_dir: ~/.local/share/llamactl/logs # Logs directory (platform dependent)
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
max_instances: -1 # Max instances (-1 = unlimited)
max_running_instances: -1 # Max running instances (-1 = unlimited)
@@ -195,6 +196,12 @@ instances:
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
timeout_check_interval: 5 # Idle instance timeout check in minutes
database:
path: ~/.local/share/llamactl/llamactl.db # Database file path (platform dependent)
max_open_connections: 25 # Maximum open database connections
max_idle_connections: 5 # Maximum idle database connections
connection_max_lifetime: 5m # Connection max lifetime
auth:
require_inference_auth: true # Require auth for inference endpoints
inference_keys: [] # Keys for inference endpoints

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"llamactl/pkg/config"
"llamactl/pkg/database"
"llamactl/pkg/manager"
"llamactl/pkg/server"
"log"
@@ -49,17 +50,45 @@ func main() {
// Create the data directory if it doesn't exist
if cfg.Instances.AutoCreateDirs {
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
log.Printf("Error creating config directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
// Create the main data directory
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
log.Printf("Error creating data directory %s: %v\nData persistence may not be available.", cfg.DataDir, err)
}
// Create instances directory
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
log.Printf("Error creating instances directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
}
// Create logs directory
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
}
}
// Initialize the instance manager
instanceManager := manager.New(&cfg)
// Initialize database
db, err := database.Open(&database.Config{
Path: cfg.Database.Path,
MaxOpenConnections: cfg.Database.MaxOpenConnections,
MaxIdleConnections: cfg.Database.MaxIdleConnections,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
})
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
// 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)
}
// Initialize the instance manager with dependency injection
instanceManager := manager.New(&cfg, db)
// Create a new handler with the instance manager
handler := server.NewHandler(instanceManager, cfg)

View File

@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"fmt"
"llamactl/pkg/config"
"llamactl/pkg/database"
"llamactl/pkg/instance"
"log"
"os"
"path/filepath"
)
// migrateFromJSON migrates instances from JSON files to SQLite database
// This is a one-time migration that runs on first startup with existing JSON files.
func migrateFromJSON(cfg *config.AppConfig, db database.DB) error {
instancesDir := cfg.Instances.InstancesDir
if instancesDir == "" {
return nil // No instances directory configured
}
// Check if instances directory exists
if _, err := os.Stat(instancesDir); os.IsNotExist(err) {
return nil // No instances directory, nothing to migrate
}
// Check if database is empty (no instances)
existing, err := db.LoadAll()
if err != nil {
return fmt.Errorf("failed to check existing instances: %w", err)
}
if len(existing) > 0 {
return nil // Database already has instances, skip migration
}
// Find all JSON files
files, err := filepath.Glob(filepath.Join(instancesDir, "*.json"))
if err != nil {
return fmt.Errorf("failed to list instance files: %w", err)
}
if len(files) == 0 {
return nil // No JSON files to migrate
}
log.Printf("Migrating %d instances from JSON to SQLite...", len(files))
// Migrate each JSON file
var migrated int
for _, file := range files {
if err := migrateJSONFile(file, db); err != nil {
log.Printf("Failed to migrate %s: %v", file, err)
continue
}
migrated++
}
log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files))
// Archive old JSON files
if migrated > 0 {
archiveDir := filepath.Join(instancesDir, "json_archive")
if err := os.MkdirAll(archiveDir, 0755); err == nil {
for _, file := range files {
newPath := filepath.Join(archiveDir, filepath.Base(file))
if err := os.Rename(file, newPath); err != nil {
log.Printf("Failed to archive %s: %v", file, err)
}
}
log.Printf("Archived old JSON files to %s", archiveDir)
}
}
return nil
}
// migrateJSONFile migrates a single JSON file to the database
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)
}
var inst instance.Instance
if err := json.Unmarshal(data, &inst); err != nil {
return fmt.Errorf("failed to unmarshal instance: %w", err)
}
if err := db.Save(&inst); err != nil {
return fmt.Errorf("failed to save instance to database: %w", err)
}
log.Printf("Migrated instance %s from JSON to SQLite", inst.Name)
return nil
}

View File

@@ -49,11 +49,12 @@ backends:
environment: {} # Environment variables for the backend process
response_headers: {} # Additional response headers to send with responses
data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS
instances:
port_range: [8000, 9000] # Port range for instances
data_dir: ~/.local/share/llamactl # Data directory (platform-specific, see below)
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory
logs_dir: ~/.local/share/llamactl/logs # Logs directory
configs_dir: data_dir/instances # Instance configs directory
logs_dir: data_dir/logs # Logs directory
auto_create_dirs: true # Auto-create data/config/logs dirs if missing
max_instances: -1 # Max instances (-1 = unlimited)
max_running_instances: -1 # Max running instances (-1 = unlimited)
@@ -65,6 +66,12 @@ instances:
on_demand_start_timeout: 120 # Default on-demand start timeout in seconds
timeout_check_interval: 5 # Idle instance timeout check in minutes
database:
path: data_dir/llamactl.db # Database file path
max_open_connections: 25 # Maximum open database connections
max_idle_connections: 5 # Maximum idle database connections
connection_max_lifetime: 5m # Connection max lifetime
auth:
require_inference_auth: true # Require auth for inference endpoints
inference_keys: [] # Keys for inference endpoints
@@ -193,14 +200,27 @@ backends:
- `LLAMACTL_MLX_ENV` - Environment variables in format "KEY1=value1,KEY2=value2"
- `LLAMACTL_MLX_RESPONSE_HEADERS` - Response headers in format "KEY1=value1;KEY2=value2"
### Data Directory Configuration
```yaml
data_dir: "~/.local/share/llamactl" # Main data directory for database, instances, and logs (default varies by OS)
```
**Environment Variables:**
- `LLAMACTL_DATA_DIRECTORY` - Main data directory path
**Default Data Directory by Platform:**
- **Linux**: `~/.local/share/llamactl`
- **macOS**: `~/Library/Application Support/llamactl`
- **Windows**: `%LOCALAPPDATA%\llamactl` or `%PROGRAMDATA%\llamactl`
### Instance Configuration
```yaml
instances:
port_range: [8000, 9000] # Port range for instances (default: [8000, 9000])
data_dir: "~/.local/share/llamactl" # Directory for all llamactl data (default varies by OS)
configs_dir: "~/.local/share/llamactl/instances" # Directory for instance configs (default: data_dir/instances)
logs_dir: "~/.local/share/llamactl/logs" # Directory for instance logs (default: data_dir/logs)
configs_dir: "instances" # Directory for instance configs, default: data_dir/instances
logs_dir: "logs" # Directory for instance logs, default: data_dir/logs
auto_create_dirs: true # Automatically create data/config/logs directories (default: true)
max_instances: -1 # Maximum instances (-1 = unlimited)
max_running_instances: -1 # Maximum running instances (-1 = unlimited)
@@ -215,7 +235,6 @@ instances:
**Environment Variables:**
- `LLAMACTL_INSTANCE_PORT_RANGE` - Port range (format: "8000-9000" or "8000,9000")
- `LLAMACTL_DATA_DIRECTORY` - Data directory path
- `LLAMACTL_INSTANCES_DIR` - Instance configs directory path
- `LLAMACTL_LOGS_DIR` - Log directory path
- `LLAMACTL_AUTO_CREATE_DATA_DIR` - Auto-create data/config/logs directories (true/false)
@@ -229,6 +248,22 @@ instances:
- `LLAMACTL_ON_DEMAND_START_TIMEOUT` - Default on-demand start timeout in seconds
- `LLAMACTL_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes
### Database Configuration
```yaml
database:
path: "llamactl.db" # Database file path, default: data_dir/llamactl.db
max_open_connections: 25 # Maximum open database connections (default: 25)
max_idle_connections: 5 # Maximum idle database connections (default: 5)
connection_max_lifetime: 5m # Connection max lifetime (default: 5m)
```
**Environment Variables:**
- `LLAMACTL_DATABASE_PATH` - Database file path (relative to data_dir or absolute)
- `LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS` - Maximum open database connections
- `LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS` - Maximum idle database connections
- `LLAMACTL_DATABASE_CONN_MAX_LIFETIME` - Connection max lifetime (e.g., "5m", "1h")
### Authentication Configuration
```yaml

13
go.mod
View File

@@ -16,11 +16,16 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/swaggo/files v1.0.1 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/tools v0.35.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/tools v0.38.0 // indirect
)

27
go.sum
View File

@@ -1,7 +1,9 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -14,8 +16,17 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -24,10 +35,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@@ -37,21 +54,29 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -70,6 +95,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -9,6 +9,7 @@ import (
"runtime"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
@@ -42,9 +43,14 @@ type AppConfig struct {
Server ServerConfig `yaml:"server" json:"server"`
Backends BackendConfig `yaml:"backends" json:"backends"`
Instances InstancesConfig `yaml:"instances" json:"instances"`
Database DatabaseConfig `yaml:"database" json:"database"`
Auth AuthConfig `yaml:"auth" json:"auth"`
LocalNode string `yaml:"local_node,omitempty" json:"local_node,omitempty"`
Nodes map[string]NodeConfig `yaml:"nodes,omitempty" json:"nodes,omitempty"`
// Directory where all llamactl data will be stored (database, instances, logs, etc.)
DataDir string `yaml:"data_dir" json:"data_dir"`
Version string `yaml:"-" json:"version"`
CommitHash string `yaml:"-" json:"commit_hash"`
BuildTime string `yaml:"-" json:"build_time"`
@@ -71,18 +77,27 @@ type ServerConfig struct {
ResponseHeaders map[string]string `yaml:"response_headers,omitempty" json:"response_headers,omitempty"`
}
// DatabaseConfig contains database configuration settings
type DatabaseConfig struct {
// Database file path (relative to the top-level data_dir or absolute)
Path string `yaml:"path" json:"path"`
// Connection settings
MaxOpenConnections int `yaml:"max_open_connections" json:"max_open_connections"`
MaxIdleConnections int `yaml:"max_idle_connections" json:"max_idle_connections"`
ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime"`
}
// InstancesConfig contains instance management configuration
type InstancesConfig struct {
// Port range for instances (e.g., 8000,9000)
PortRange [2]int `yaml:"port_range" json:"port_range"`
// Directory where all llamactl data will be stored (instances.json, logs, etc.)
DataDir string `yaml:"data_dir" json:"data_dir"`
// Instance config directory override
// Instance config directory override (relative to data_dir if not absolute)
InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
// Logs directory override
// Logs directory override (relative to data_dir if not absolute)
LogsDir string `yaml:"logs_dir" json:"logs_dir"`
// Automatically create the data directory if it doesn't exist
@@ -143,6 +158,8 @@ type NodeConfig struct {
// 3. Environment variables
func LoadConfig(configPath string) (AppConfig, error) {
// 1. Start with defaults
defaultDataDir := getDefaultDataDirectory()
cfg := AppConfig{
Server: ServerConfig{
Host: "0.0.0.0",
@@ -153,6 +170,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
},
LocalNode: "main",
Nodes: map[string]NodeConfig{},
DataDir: defaultDataDir,
Backends: BackendConfig{
LlamaCpp: BackendSettings{
Command: "llama-server",
@@ -163,7 +181,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
Image: "ghcr.io/ggml-org/llama.cpp:server",
Args: []string{
"run", "--rm", "--network", "host", "--gpus", "all",
"-v", filepath.Join(getDefaultDataDirectory(), "llama.cpp") + ":/root/.cache/llama.cpp"},
"-v", filepath.Join(defaultDataDir, "llama.cpp") + ":/root/.cache/llama.cpp"},
Environment: map[string]string{},
},
},
@@ -175,7 +193,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
Image: "vllm/vllm-openai:latest",
Args: []string{
"run", "--rm", "--network", "host", "--gpus", "all", "--shm-size", "1g",
"-v", filepath.Join(getDefaultDataDirectory(), "huggingface") + ":/root/.cache/huggingface",
"-v", filepath.Join(defaultDataDir, "huggingface") + ":/root/.cache/huggingface",
},
Environment: map[string]string{},
},
@@ -188,7 +206,6 @@ func LoadConfig(configPath string) (AppConfig, error) {
},
Instances: InstancesConfig{
PortRange: [2]int{8000, 9000},
DataDir: getDefaultDataDirectory(),
// NOTE: empty strings are set as placeholder values since InstancesDir and LogsDir
// should be relative path to DataDir if not explicitly set.
InstancesDir: "",
@@ -204,6 +221,12 @@ func LoadConfig(configPath string) (AppConfig, error) {
OnDemandStartTimeout: 120, // 2 minutes
TimeoutCheckInterval: 5, // Check timeouts every 5 minutes
},
Database: DatabaseConfig{
Path: "", // Will be set to data_dir/llamactl.db if empty
MaxOpenConnections: 25,
MaxIdleConnections: 5,
ConnMaxLifetime: 5 * time.Minute,
},
Auth: AuthConfig{
RequireInferenceAuth: true,
InferenceKeys: []string{},
@@ -225,12 +248,15 @@ func LoadConfig(configPath string) (AppConfig, error) {
// 3. Override with environment variables
loadEnvVars(&cfg)
// If InstancesDir or LogsDir is not set, set it to relative path of DataDir
// Set default directories if not specified
if cfg.Instances.InstancesDir == "" {
cfg.Instances.InstancesDir = filepath.Join(cfg.Instances.DataDir, "instances")
cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances")
}
if cfg.Instances.LogsDir == "" {
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs")
}
if cfg.Database.Path == "" {
cfg.Database.Path = filepath.Join(cfg.DataDir, "llamactl.db")
}
// Validate port range
@@ -288,7 +314,7 @@ func loadEnvVars(cfg *AppConfig) {
// Data config
if dataDir := os.Getenv("LLAMACTL_DATA_DIRECTORY"); dataDir != "" {
cfg.Instances.DataDir = dataDir
cfg.DataDir = dataDir
}
if instancesDir := os.Getenv("LLAMACTL_INSTANCES_DIR"); instancesDir != "" {
cfg.Instances.InstancesDir = instancesDir
@@ -495,6 +521,26 @@ func loadEnvVars(cfg *AppConfig) {
if localNode := os.Getenv("LLAMACTL_LOCAL_NODE"); localNode != "" {
cfg.LocalNode = localNode
}
// Database config
if dbPath := os.Getenv("LLAMACTL_DATABASE_PATH"); dbPath != "" {
cfg.Database.Path = dbPath
}
if maxOpenConns := os.Getenv("LLAMACTL_DATABASE_MAX_OPEN_CONNECTIONS"); maxOpenConns != "" {
if m, err := strconv.Atoi(maxOpenConns); err == nil {
cfg.Database.MaxOpenConnections = m
}
}
if maxIdleConns := os.Getenv("LLAMACTL_DATABASE_MAX_IDLE_CONNECTIONS"); maxIdleConns != "" {
if m, err := strconv.Atoi(maxIdleConns); err == nil {
cfg.Database.MaxIdleConnections = m
}
}
if connMaxLifetime := os.Getenv("LLAMACTL_DATABASE_CONN_MAX_LIFETIME"); connMaxLifetime != "" {
if d, err := time.ParseDuration(connMaxLifetime); err == nil {
cfg.Database.ConnMaxLifetime = d
}
}
}
// ParsePortRange parses port range from string formats like "8000-9000" or "8000,9000"

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

@@ -0,0 +1,107 @@
package database
import (
"database/sql"
"fmt"
"llamactl/pkg/instance"
"log"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// DB defines the interface for instance persistence operations
type DB 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
}
// 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) (*sqliteDB, 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
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 &sqliteDB{
DB: sqlDB,
config: config,
}, nil
}
// Close closes the database connection
func (db *sqliteDB) 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 *sqliteDB) HealthCheck() error {
if db.DB == nil {
return fmt.Errorf("database connection is nil")
}
return db.DB.Ping()
}

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

@@ -0,0 +1,319 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"llamactl/pkg/instance"
"log"
"time"
)
// instanceRow represents a row in the instances table
type instanceRow struct {
ID int
Name string
Status string
CreatedAt int64
UpdatedAt int64
OptionsJSON string
OwnerUserID sql.NullString
}
// Create inserts a new instance into the database
func (db *sqliteDB) 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, status, created_at, updated_at, options_json, owner_user_id
) VALUES (?, ?, ?, ?, ?, ?)
`
_, err = db.DB.ExecContext(ctx, query,
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
)
if err != nil {
return fmt.Errorf("failed to insert instance: %w", err)
}
return nil
}
// GetByName retrieves an instance by name
func (db *sqliteDB) GetByName(ctx context.Context, name string) (*instance.Instance, error) {
query := `
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.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &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 *sqliteDB) GetAll(ctx context.Context) ([]*instance.Instance, error) {
query := `
SELECT id, name, status, created_at, updated_at, options_json, 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.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &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 *sqliteDB) 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
status = ?, updated_at = ?, options_json = ?
WHERE name = ?
`
result, err := db.DB.ExecContext(ctx, query,
row.Status, row.UpdatedAt, row.OptionsJSON, 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 *sqliteDB) 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 *sqliteDB) 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 *sqliteDB) instanceToRow(inst *instance.Instance) (*instanceRow, error) {
opts := inst.GetOptions()
if opts == nil {
return nil, fmt.Errorf("instance options cannot be nil")
}
// Marshal options to JSON using the existing MarshalJSON method
optionsJSON, err := json.Marshal(opts)
if err != nil {
return nil, fmt.Errorf("failed to marshal options: %w", err)
}
// 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,
Status: statusStr,
CreatedAt: inst.Created,
UpdatedAt: time.Now().Unix(),
OptionsJSON: string(optionsJSON),
}, nil
}
// rowToInstance converts a database row to an Instance
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)
}
// 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 instance: %w", err)
}
// 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)
}
// 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
}
// Database interface implementation
// Save saves an instance to the database (insert or update)
func (db *sqliteDB) 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 *sqliteDB) Delete(name string) error {
ctx := context.Background()
return db.DeleteInstance(ctx, name)
}
// LoadAll loads all instances from the database
func (db *sqliteDB) LoadAll() ([]*instance.Instance, error) {
ctx := context.Background()
return db.GetAll(ctx)
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
-- -----------------------------------------------------------------------------
-- 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,
-- 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,
-- All instance options stored as a single JSON blob
options_json TEXT NOT NULL,
-- 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);

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"llamactl/pkg/config"
"llamactl/pkg/database"
"llamactl/pkg/instance"
"log"
"sync"
@@ -30,7 +31,7 @@ type instanceManager struct {
// Components (each with own synchronization)
registry *instanceRegistry
ports *portAllocator
persistence *instancePersister
db database.DB
remote *remoteManager
lifecycle *lifecycleManager
@@ -42,8 +43,8 @@ type instanceManager struct {
shutdownOnce sync.Once
}
// New creates a new instance of InstanceManager.
func New(globalConfig *config.AppConfig) InstanceManager {
// New creates a new instance of InstanceManager with dependency injection.
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
@@ -56,9 +57,6 @@ func New(globalConfig *config.AppConfig) InstanceManager {
portRange := globalConfig.Instances.PortRange
ports := newPortAllocator(portRange[0], portRange[1])
// Initialize persistence
persistence := newInstancePersister(globalConfig.Instances.InstancesDir)
// Initialize remote manager
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
@@ -66,7 +64,7 @@ func New(globalConfig *config.AppConfig) InstanceManager {
im := &instanceManager{
registry: registry,
ports: ports,
persistence: persistence,
db: db,
remote: remote,
globalConfig: globalConfig,
}
@@ -86,9 +84,9 @@ func New(globalConfig *config.AppConfig) InstanceManager {
return im
}
// persistInstance saves an instance using the persistence component
// persistInstance saves an instance using the persistence layer
func (im *instanceManager) persistInstance(inst *instance.Instance) error {
return im.persistence.save(inst)
return im.db.Save(inst)
}
func (im *instanceManager) Shutdown() {
@@ -116,13 +114,18 @@ func (im *instanceManager) Shutdown() {
}
wg.Wait()
fmt.Println("All instances stopped.")
// 4. Close database connection
if err := im.db.Close(); err != nil {
log.Printf("Error closing database: %v\n", err)
}
})
}
// loadInstances restores all instances from disk using the persistence component
// loadInstances restores all instances from the persistence layer
func (im *instanceManager) loadInstances() error {
// Load all instances from persistence
instances, err := im.persistence.loadAll()
instances, err := im.db.LoadAll()
if err != nil {
return fmt.Errorf("failed to load instances: %w", err)
}
@@ -256,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 {

View File

@@ -4,20 +4,34 @@ import (
"fmt"
"llamactl/pkg/backends"
"llamactl/pkg/config"
"llamactl/pkg/database"
"llamactl/pkg/instance"
"llamactl/pkg/manager"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestManager_PersistsAndLoadsInstances(t *testing.T) {
tempDir := t.TempDir()
appConfig := createTestAppConfig(tempDir)
// Use file-based database for this test since we need to persist across connections
appConfig.Database.Path = tempDir + "/test.db"
// Create instance and check file was created
manager1 := manager.New(appConfig)
// Create instance and check database was created
db1, err := database.Open(&database.Config{
Path: appConfig.Database.Path,
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
})
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
if err := database.RunMigrations(db1); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
manager1 := manager.New(appConfig, db1)
options := &instance.Options{
BackendOptions: backends.Options{
BackendType: backends.BackendTypeLlamaCpp,
@@ -28,18 +42,28 @@ func TestManager_PersistsAndLoadsInstances(t *testing.T) {
},
}
_, err := manager1.CreateInstance("test-instance", options)
_, err = manager1.CreateInstance("test-instance", options)
if err != nil {
t.Fatalf("CreateInstance failed: %v", err)
}
expectedPath := filepath.Join(tempDir, "test-instance.json")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("Expected persistence file %s to exist", expectedPath)
}
// Shutdown first manager to close database connection
manager1.Shutdown()
// Load instances from disk
manager2 := manager.New(appConfig)
// Load instances from database
db2, err := database.Open(&database.Config{
Path: appConfig.Database.Path,
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
})
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
if err := database.RunMigrations(db2); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
manager2 := manager.New(appConfig, db2)
instances, err := manager2.ListInstances()
if err != nil {
t.Fatalf("ListInstances failed: %v", err)
@@ -50,13 +74,29 @@ func TestManager_PersistsAndLoadsInstances(t *testing.T) {
if instances[0].Name != "test-instance" {
t.Errorf("Expected loaded instance name 'test-instance', got %q", instances[0].Name)
}
manager2.Shutdown()
}
func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
func TestDeleteInstance_RemovesFromDatabase(t *testing.T) {
tempDir := t.TempDir()
appConfig := createTestAppConfig(tempDir)
mgr := manager.New(appConfig)
db, err := database.Open(&database.Config{
Path: appConfig.Database.Path,
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
})
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
if err := database.RunMigrations(db); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
mgr := manager.New(appConfig, db)
defer mgr.Shutdown()
options := &instance.Options{
BackendOptions: backends.Options{
BackendType: backends.BackendTypeLlamaCpp,
@@ -67,20 +107,33 @@ func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
},
}
_, err := mgr.CreateInstance("test-instance", options)
_, err = mgr.CreateInstance("test-instance", options)
if err != nil {
t.Fatalf("CreateInstance failed: %v", err)
}
expectedPath := filepath.Join(tempDir, "test-instance.json")
// Verify instance exists
instances, err := mgr.ListInstances()
if err != nil {
t.Fatalf("ListInstances failed: %v", err)
}
if len(instances) != 1 {
t.Fatalf("Expected 1 instance, got %d", len(instances))
}
// Delete instance
err = mgr.DeleteInstance("test-instance")
if err != nil {
t.Fatalf("DeleteInstance failed: %v", err)
}
if _, err := os.Stat(expectedPath); !os.IsNotExist(err) {
t.Error("Expected persistence file to be deleted")
// Verify instance was deleted from database
instances, err = mgr.ListInstances()
if err != nil {
t.Fatalf("ListInstances failed: %v", err)
}
if len(instances) != 0 {
t.Errorf("Expected 0 instances after deletion, got %d", len(instances))
}
}
@@ -158,6 +211,12 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
DefaultRestartDelay: 5,
TimeoutCheckInterval: 5,
},
Database: config.DatabaseConfig{
Path: ":memory:",
MaxOpenConnections: 25,
MaxIdleConnections: 5,
ConnMaxLifetime: 5 * time.Minute,
},
LocalNode: "main",
Nodes: map[string]config.NodeConfig{},
}
@@ -166,5 +225,17 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
func createTestManager(t *testing.T) manager.InstanceManager {
tempDir := t.TempDir()
appConfig := createTestAppConfig(tempDir)
return manager.New(appConfig)
db, err := database.Open(&database.Config{
Path: appConfig.Database.Path,
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
})
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
if err := database.RunMigrations(db); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
return manager.New(appConfig, db)
}

View File

@@ -317,9 +317,9 @@ func (im *instanceManager) DeleteInstance(name string) error {
im.remote.removeInstance(name)
im.registry.remove(name)
// Delete the instance's persistence file
if err := im.persistence.delete(name); err != nil {
return fmt.Errorf("failed to delete config file for remote instance %s: %w", name, err)
// Delete the instance's persistence
if err := im.db.Delete(name); err != nil {
return fmt.Errorf("failed to delete remote instance %s: %w", name, err)
}
return nil
@@ -343,9 +343,9 @@ func (im *instanceManager) DeleteInstance(name string) error {
return fmt.Errorf("failed to remove instance from registry: %w", err)
}
// Delete persistence file
if err := im.persistence.delete(name); err != nil {
return fmt.Errorf("failed to delete config file for instance %s: %w", name, err)
// Delete from persistence
if err := im.db.Delete(name); err != nil {
return fmt.Errorf("failed to delete instance from persistence %s: %w", name, err)
}
return nil

View File

@@ -3,10 +3,12 @@ package manager_test
import (
"llamactl/pkg/backends"
"llamactl/pkg/config"
"llamactl/pkg/database"
"llamactl/pkg/instance"
"llamactl/pkg/manager"
"strings"
"testing"
"time"
)
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
@@ -49,10 +51,28 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
MaxInstances: 1, // Very low limit for testing
TimeoutCheckInterval: 5,
},
Database: config.DatabaseConfig{
Path: ":memory:",
MaxOpenConnections: 25,
MaxIdleConnections: 5,
ConnMaxLifetime: 5 * time.Minute,
},
LocalNode: "main",
Nodes: map[string]config.NodeConfig{},
}
limitedManager := manager.New(appConfig)
db, err := database.Open(&database.Config{
Path: appConfig.Database.Path,
MaxOpenConnections: appConfig.Database.MaxOpenConnections,
MaxIdleConnections: appConfig.Database.MaxIdleConnections,
ConnMaxLifetime: appConfig.Database.ConnMaxLifetime,
})
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
if err := database.RunMigrations(db); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
limitedManager := manager.New(appConfig, db)
options := &instance.Options{
BackendOptions: backends.Options{
@@ -63,7 +83,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
},
}
_, err := limitedManager.CreateInstance("instance1", options)
_, err = limitedManager.CreateInstance("instance1", options)
if err != nil {
t.Fatalf("CreateInstance 1 failed: %v", err)
}

View File

@@ -1,198 +0,0 @@
package manager
import (
"encoding/json"
"fmt"
"llamactl/pkg/instance"
"log"
"os"
"path/filepath"
"strings"
"sync"
)
// instancePersister provides atomic file-based persistence with durability guarantees.
type instancePersister struct {
mu sync.Mutex
instancesDir string
}
// newInstancePersister creates a new instance persister.
// If instancesDir is empty, persistence is disabled.
func newInstancePersister(instancesDir string) *instancePersister {
return &instancePersister{
instancesDir: instancesDir,
}
}
// Save persists an instance to disk with atomic write
func (p *instancePersister) save(inst *instance.Instance) error {
if inst == nil {
return fmt.Errorf("cannot save nil instance")
}
// Validate instance name to prevent path traversal
validatedName, err := p.validateInstanceName(inst.Name)
if err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
instancePath := filepath.Join(p.instancesDir, validatedName+".json")
tempPath := instancePath + ".tmp"
// Serialize instance to JSON
jsonData, err := json.MarshalIndent(inst, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal instance %s: %w", inst.Name, err)
}
// Create temporary file
tempFile, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to create temp file for instance %s: %w", inst.Name, err)
}
// Write data to temporary file
if _, err := tempFile.Write(jsonData); err != nil {
tempFile.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to write temp file for instance %s: %w", inst.Name, err)
}
// Sync to disk before rename to ensure durability
if err := tempFile.Sync(); err != nil {
tempFile.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to sync temp file for instance %s: %w", inst.Name, err)
}
// Close the file
if err := tempFile.Close(); err != nil {
os.Remove(tempPath)
return fmt.Errorf("failed to close temp file for instance %s: %w", inst.Name, err)
}
// Atomic rename (this is atomic on POSIX systems)
if err := os.Rename(tempPath, instancePath); err != nil {
os.Remove(tempPath)
return fmt.Errorf("failed to rename temp file for instance %s: %w", inst.Name, err)
}
return nil
}
// Delete removes an instance's persistence file from disk.
func (p *instancePersister) delete(name string) error {
validatedName, err := p.validateInstanceName(name)
if err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
instancePath := filepath.Join(p.instancesDir, validatedName+".json")
if err := os.Remove(instancePath); err != nil {
if os.IsNotExist(err) {
// Not an error if file doesn't exist
return nil
}
return fmt.Errorf("failed to delete instance file for %s: %w", name, err)
}
return nil
}
// LoadAll loads all persisted instances from disk.
// Returns a slice of instances and any errors encountered during loading.
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Check if instances directory exists
if _, err := os.Stat(p.instancesDir); os.IsNotExist(err) {
return nil, nil // No instances directory, return empty list
}
// Read all JSON files from instances directory
files, err := os.ReadDir(p.instancesDir)
if err != nil {
return nil, fmt.Errorf("failed to read instances directory: %w", err)
}
instances := make([]*instance.Instance, 0)
var loadErrors []string
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
continue
}
instanceName := strings.TrimSuffix(file.Name(), ".json")
instancePath := filepath.Join(p.instancesDir, file.Name())
inst, err := p.loadInstanceFile(instanceName, instancePath)
if err != nil {
log.Printf("Failed to load instance %s: %v", instanceName, err)
loadErrors = append(loadErrors, fmt.Sprintf("%s: %v", instanceName, err))
continue
}
instances = append(instances, inst)
}
if len(loadErrors) > 0 {
log.Printf("Loaded %d instances with %d errors", len(instances), len(loadErrors))
} else if len(instances) > 0 {
log.Printf("Loaded %d instances from persistence", len(instances))
}
return instances, nil
}
// loadInstanceFile is an internal helper that loads a single instance file.
// Note: This assumes the mutex is already held by the caller.
func (p *instancePersister) loadInstanceFile(name, path string) (*instance.Instance, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read instance file: %w", err)
}
var inst instance.Instance
if err := json.Unmarshal(data, &inst); err != nil {
return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
}
// Validate the instance name matches the filename
if inst.Name != name {
return nil, fmt.Errorf("instance name mismatch: file=%s, instance.Name=%s", name, inst.Name)
}
return &inst, nil
}
// validateInstanceName ensures the instance name is safe for filesystem operations.
// Returns the validated name if valid, or an error if invalid.
func (p *instancePersister) validateInstanceName(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("instance name cannot be empty")
}
// Check for path separators and parent directory references
// This prevents path traversal attacks
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return "", fmt.Errorf("invalid instance name: %s (cannot contain path separators or '..')", name)
}
// Additional check: ensure the name doesn't start with a dot (hidden files)
// or contain any other suspicious characters
if strings.HasPrefix(name, ".") {
return "", fmt.Errorf("invalid instance name: %s (cannot start with '.')", name)
}
return name, nil
}

View File

@@ -30,7 +30,6 @@ export interface ServerConfig {
export interface InstancesConfig {
port_range: [number, number]
data_dir: string
configs_dir: string
logs_dir: string
auto_create_dirs: boolean
@@ -45,6 +44,13 @@ export interface InstancesConfig {
timeout_check_interval: number
}
export interface DatabaseConfig {
path: string
max_open_connections: number
max_idle_connections: number
connection_max_lifetime: number
}
export interface AuthConfig {
require_inference_auth: boolean
inference_keys: string[] // Will be empty in sanitized response
@@ -61,9 +67,11 @@ export interface AppConfig {
server: ServerConfig
backends: BackendConfig
instances: InstancesConfig
database: DatabaseConfig
auth: AuthConfig
local_node: string
nodes: Record<string, NodeConfig>
data_dir: string
version?: string
commit_hash?: string
build_time?: string