From fec989fee26c65ae1320cd4772b4c0a27157fa39 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 30 Nov 2025 00:12:03 +0100 Subject: [PATCH 1/6] Implement SQLite database persistence for instance management --- cmd/server/main.go | 26 +- cmd/server/migrate_json.go | 99 ++++ go.mod | 13 +- go.sum | 27 + pkg/config/config.go | 44 ++ pkg/database/database.go | 107 ++++ pkg/database/instances.go | 539 ++++++++++++++++++ pkg/database/migrations.go | 78 +++ .../migrations/001_initial_schema.down.sql | 7 + .../migrations/001_initial_schema.up.sql | 43 ++ pkg/manager/manager.go | 33 +- pkg/manager/manager_test.go | 107 +++- pkg/manager/operations.go | 12 +- pkg/manager/operations_test.go | 24 +- pkg/manager/persistence.go | 198 ------- spec.md | 213 +++++++ 16 files changed, 1325 insertions(+), 245 deletions(-) create mode 100644 cmd/server/migrate_json.go create mode 100644 pkg/database/database.go create mode 100644 pkg/database/instances.go create mode 100644 pkg/database/migrations.go create mode 100644 pkg/database/migrations/001_initial_schema.down.sql create mode 100644 pkg/database/migrations/001_initial_schema.up.sql delete mode 100644 pkg/manager/persistence.go create mode 100644 spec.md diff --git a/cmd/server/main.go b/cmd/server/main.go index 5fbe503..ffa0c27 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "llamactl/pkg/config" + "llamactl/pkg/database" "llamactl/pkg/manager" "llamactl/pkg/server" "log" @@ -58,8 +59,29 @@ func main() { } } - // 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) diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go new file mode 100644 index 0000000..82a02ac --- /dev/null +++ b/cmd/server/migrate_json.go @@ -0,0 +1,99 @@ +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. +// +// 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 + 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.Database) 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 +} diff --git a/go.mod b/go.mod index 43f154a..73b77f8 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 8e3b3d0..8924797 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/config/config.go b/pkg/config/config.go index 5f85f20..b9b9ec8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "runtime" "strconv" "strings" + "time" "gopkg.in/yaml.v3" ) @@ -42,6 +43,7 @@ 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"` @@ -71,6 +73,17 @@ 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 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) @@ -204,6 +217,12 @@ func LoadConfig(configPath string) (AppConfig, error) { OnDemandStartTimeout: 120, // 2 minutes TimeoutCheckInterval: 5, // Check timeouts every 5 minutes }, + Database: DatabaseConfig{ + Path: "llamactl.db", // Relative to data_dir + MaxOpenConnections: 25, + MaxIdleConnections: 5, + ConnMaxLifetime: 5 * time.Minute, + }, Auth: AuthConfig{ RequireInferenceAuth: true, InferenceKeys: []string{}, @@ -233,6 +252,11 @@ func LoadConfig(configPath string) (AppConfig, error) { cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs") } + // Resolve database path relative to DataDir if it's not absolute + if cfg.Database.Path != "" && !filepath.IsAbs(cfg.Database.Path) { + cfg.Database.Path = filepath.Join(cfg.Instances.DataDir, cfg.Database.Path) + } + // Validate port range if cfg.Instances.PortRange[0] <= 0 || cfg.Instances.PortRange[1] <= 0 || cfg.Instances.PortRange[0] >= cfg.Instances.PortRange[1] { return AppConfig{}, fmt.Errorf("invalid port range: %v", cfg.Instances.PortRange) @@ -495,6 +519,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" diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..516425c --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,107 @@ +package database + +import ( + "database/sql" + "fmt" + "llamactl/pkg/instance" + "log" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Database defines the interface for instance persistence operations +type Database interface { + Save(inst *instance.Instance) error + Delete(name string) error + LoadAll() ([]*instance.Instance, error) + Close() error +} + +// Config contains database configuration settings +type Config struct { + // Database file path (relative to data_dir or absolute) + Path string + + // Connection settings + MaxOpenConnections int + MaxIdleConnections int + ConnMaxLifetime time.Duration +} + +// DB wraps the database connection with configuration +type DB struct { + *sql.DB + config *Config +} + +// Open creates a new database connection with the provided configuration +func Open(config *Config) (*DB, error) { + if config == nil { + return nil, fmt.Errorf("database config cannot be nil") + } + + if config.Path == "" { + return nil, fmt.Errorf("database path cannot be empty") + } + + // Ensure the database directory exists + dbDir := filepath.Dir(config.Path) + if dbDir != "." && dbDir != "/" { + // Directory will be created by the manager if auto_create_dirs is enabled + log.Printf("Database will be created at: %s", config.Path) + } + + // Open SQLite database with proper options + // - _journal_mode=WAL: Write-Ahead Logging for better concurrency + // - _busy_timeout=5000: Wait up to 5 seconds if database is locked + // - _foreign_keys=1: Enable foreign key constraints (for future use) + dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path) + + sqlDB, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Configure connection pool + if config.MaxOpenConnections > 0 { + sqlDB.SetMaxOpenConns(config.MaxOpenConnections) + } + if config.MaxIdleConnections > 0 { + sqlDB.SetMaxIdleConns(config.MaxIdleConnections) + } + if config.ConnMaxLifetime > 0 { + sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime) + } + + // Verify database connection + if err := sqlDB.Ping(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Printf("Database connection established: %s", config.Path) + + return &DB{ + DB: sqlDB, + config: config, + }, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + if db.DB != nil { + log.Println("Closing database connection") + return db.DB.Close() + } + return nil +} + +// HealthCheck verifies the database is accessible +func (db *DB) HealthCheck() error { + if db.DB == nil { + return fmt.Errorf("database connection is nil") + } + return db.DB.Ping() +} diff --git a/pkg/database/instances.go b/pkg/database/instances.go new file mode 100644 index 0000000..721ea96 --- /dev/null +++ b/pkg/database/instances.go @@ -0,0 +1,539 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "llamactl/pkg/backends" + "llamactl/pkg/instance" + "log" + "time" +) + +// instanceRow represents a row in the instances table +type instanceRow struct { + ID int + Name string + BackendType string + BackendConfigJSON string + Status string + CreatedAt int64 + UpdatedAt int64 + AutoRestart int + MaxRestarts int + RestartDelay int + OnDemandStart int + IdleTimeout int + DockerEnabled int + CommandOverride sql.NullString + Nodes sql.NullString + Environment sql.NullString + OwnerUserID sql.NullString +} + +// Create inserts a new instance into the database +func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { + if inst == nil { + return fmt.Errorf("instance cannot be nil") + } + + opts := inst.GetOptions() + if opts == nil { + return fmt.Errorf("instance options cannot be nil") + } + + // Convert instance to database row + row, err := db.instanceToRow(inst) + if err != nil { + return fmt.Errorf("failed to convert instance to row: %w", err) + } + + // Insert into database + query := ` + INSERT INTO instances ( + name, backend_type, backend_config_json, status, + created_at, updated_at, + auto_restart, max_restarts, restart_delay, + on_demand_start, idle_timeout, docker_enabled, + command_override, nodes, environment, owner_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + _, err = db.DB.ExecContext(ctx, query, + row.Name, row.BackendType, row.BackendConfigJSON, row.Status, + row.CreatedAt, row.UpdatedAt, + row.AutoRestart, row.MaxRestarts, row.RestartDelay, + row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, + row.CommandOverride, row.Nodes, row.Environment, row.OwnerUserID, + ) + + if err != nil { + return fmt.Errorf("failed to insert instance: %w", err) + } + + return nil +} + +// GetByName retrieves an instance by name +func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, error) { + query := ` + SELECT id, name, backend_type, backend_config_json, status, + created_at, updated_at, + auto_restart, max_restarts, restart_delay, + on_demand_start, idle_timeout, docker_enabled, + command_override, nodes, environment, owner_user_id + FROM instances + WHERE name = ? + ` + + var row instanceRow + err := db.DB.QueryRowContext(ctx, query, name).Scan( + &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, + &row.CreatedAt, &row.UpdatedAt, + &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, + &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, + &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("instance not found: %s", name) + } + if err != nil { + return nil, fmt.Errorf("failed to query instance: %w", err) + } + + return db.rowToInstance(&row) +} + +// GetAll retrieves all instances from the database +func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { + query := ` + SELECT id, name, backend_type, backend_config_json, status, + created_at, updated_at, + auto_restart, max_restarts, restart_delay, + on_demand_start, idle_timeout, docker_enabled, + command_override, nodes, environment, owner_user_id + FROM instances + ORDER BY created_at ASC + ` + + rows, err := db.DB.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query instances: %w", err) + } + defer rows.Close() + + var instances []*instance.Instance + for rows.Next() { + var row instanceRow + err := rows.Scan( + &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, + &row.CreatedAt, &row.UpdatedAt, + &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, + &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, + &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + ) + if err != nil { + log.Printf("Failed to scan instance row: %v", err) + continue + } + + inst, err := db.rowToInstance(&row) + if err != nil { + log.Printf("Failed to convert row to instance: %v", err) + continue + } + + instances = append(instances, inst) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return instances, nil +} + +// Update updates an existing instance +func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { + if inst == nil { + return fmt.Errorf("instance cannot be nil") + } + + opts := inst.GetOptions() + if opts == nil { + return fmt.Errorf("instance options cannot be nil") + } + + // Convert instance to database row + row, err := db.instanceToRow(inst) + if err != nil { + return fmt.Errorf("failed to convert instance to row: %w", err) + } + + // Update in database + query := ` + UPDATE instances SET + backend_type = ?, backend_config_json = ?, status = ?, + updated_at = ?, + auto_restart = ?, max_restarts = ?, restart_delay = ?, + on_demand_start = ?, idle_timeout = ?, docker_enabled = ?, + command_override = ?, nodes = ?, environment = ? + WHERE name = ? + ` + + result, err := db.DB.ExecContext(ctx, query, + row.BackendType, row.BackendConfigJSON, row.Status, + row.UpdatedAt, + row.AutoRestart, row.MaxRestarts, row.RestartDelay, + row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, + row.CommandOverride, row.Nodes, row.Environment, + row.Name, + ) + + if err != nil { + return fmt.Errorf("failed to update instance: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("instance not found: %s", inst.Name) + } + + return nil +} + +// UpdateStatus updates only the status of an instance (optimized operation) +func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Status) error { + // Convert status to string + statusJSON, err := status.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to marshal status: %w", err) + } + var statusStr string + if err := json.Unmarshal(statusJSON, &statusStr); err != nil { + return fmt.Errorf("failed to unmarshal status string: %w", err) + } + + query := ` + UPDATE instances SET + status = ?, + updated_at = ? + WHERE name = ? + ` + + result, err := db.DB.ExecContext(ctx, query, statusStr, time.Now().Unix(), name) + if err != nil { + return fmt.Errorf("failed to update instance status: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("instance not found: %s", name) + } + + return nil +} + +// DeleteInstance removes an instance from the database +func (db *DB) DeleteInstance(ctx context.Context, name string) error { + query := `DELETE FROM instances WHERE name = ?` + + result, err := db.DB.ExecContext(ctx, query, name) + if err != nil { + return fmt.Errorf("failed to delete instance: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("instance not found: %s", name) + } + + return nil +} + +// instanceToRow converts an Instance to a database row +func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { + opts := inst.GetOptions() + if opts == nil { + return nil, fmt.Errorf("instance options cannot be nil") + } + + // Marshal backend options to JSON (this uses the MarshalJSON method which handles typed backends) + backendJSON, err := json.Marshal(&opts.BackendOptions) + if err != nil { + return nil, fmt.Errorf("failed to marshal backend options: %w", err) + } + + // Extract just the backend_options field from the marshaled JSON + var backendWrapper struct { + BackendOptions map[string]any `json:"backend_options"` + } + if err := json.Unmarshal(backendJSON, &backendWrapper); err != nil { + return nil, fmt.Errorf("failed to unmarshal backend wrapper: %w", err) + } + + backendConfigJSON, err := json.Marshal(backendWrapper.BackendOptions) + if err != nil { + return nil, fmt.Errorf("failed to marshal backend config: %w", err) + } + + // Convert nodes map to JSON array + var nodesJSON sql.NullString + if len(opts.Nodes) > 0 { + nodesList := make([]string, 0, len(opts.Nodes)) + for node := range opts.Nodes { + nodesList = append(nodesList, node) + } + nodesBytes, err := json.Marshal(nodesList) + if err != nil { + return nil, fmt.Errorf("failed to marshal nodes: %w", err) + } + nodesJSON = sql.NullString{String: string(nodesBytes), Valid: true} + } + + // Convert environment map to JSON + var envJSON sql.NullString + if len(opts.Environment) > 0 { + envBytes, err := json.Marshal(opts.Environment) + if err != nil { + return nil, fmt.Errorf("failed to marshal environment: %w", err) + } + envJSON = sql.NullString{String: string(envBytes), Valid: true} + } + + // Convert command override + var cmdOverride sql.NullString + if opts.CommandOverride != "" { + cmdOverride = sql.NullString{String: opts.CommandOverride, Valid: true} + } + + // Convert boolean pointers to integers (0 or 1) + autoRestart := 0 + if opts.AutoRestart != nil && *opts.AutoRestart { + autoRestart = 1 + } + + maxRestarts := -1 + if opts.MaxRestarts != nil { + maxRestarts = *opts.MaxRestarts + } + + restartDelay := 0 + if opts.RestartDelay != nil { + restartDelay = *opts.RestartDelay + } + + onDemandStart := 0 + if opts.OnDemandStart != nil && *opts.OnDemandStart { + onDemandStart = 1 + } + + idleTimeout := 0 + if opts.IdleTimeout != nil { + idleTimeout = *opts.IdleTimeout + } + + dockerEnabled := 0 + if opts.DockerEnabled != nil && *opts.DockerEnabled { + dockerEnabled = 1 + } + + now := time.Now().Unix() + + // Convert status to string + statusJSON, err := inst.GetStatus().MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal status: %w", err) + } + var statusStr string + if err := json.Unmarshal(statusJSON, &statusStr); err != nil { + return nil, fmt.Errorf("failed to unmarshal status string: %w", err) + } + + return &instanceRow{ + Name: inst.Name, + BackendType: string(opts.BackendOptions.BackendType), + BackendConfigJSON: string(backendConfigJSON), + Status: statusStr, + CreatedAt: inst.Created, + UpdatedAt: now, + AutoRestart: autoRestart, + MaxRestarts: maxRestarts, + RestartDelay: restartDelay, + OnDemandStart: onDemandStart, + IdleTimeout: idleTimeout, + DockerEnabled: dockerEnabled, + CommandOverride: cmdOverride, + Nodes: nodesJSON, + Environment: envJSON, + }, nil +} + +// rowToInstance converts a database row to an Instance +func (db *DB) rowToInstance(row *instanceRow) (*instance.Instance, error) { + // Unmarshal backend config + var backendConfig map[string]any + if err := json.Unmarshal([]byte(row.BackendConfigJSON), &backendConfig); err != nil { + return nil, fmt.Errorf("failed to unmarshal backend config: %w", err) + } + + // Create backends.Options by marshaling and unmarshaling to trigger the UnmarshalJSON logic + // This ensures the typed backend fields (LlamaServerOptions, VllmServerOptions, etc.) are populated + var backendOptions backends.Options + backendJSON, err := json.Marshal(map[string]any{ + "backend_type": row.BackendType, + "backend_options": backendConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal backend for unmarshaling: %w", err) + } + + if err := json.Unmarshal(backendJSON, &backendOptions); err != nil { + return nil, fmt.Errorf("failed to unmarshal backend options: %w", err) + } + + // Unmarshal nodes + var nodes map[string]struct{} + if row.Nodes.Valid && row.Nodes.String != "" { + var nodesList []string + if err := json.Unmarshal([]byte(row.Nodes.String), &nodesList); err != nil { + return nil, fmt.Errorf("failed to unmarshal nodes: %w", err) + } + nodes = make(map[string]struct{}, len(nodesList)) + for _, node := range nodesList { + nodes[node] = struct{}{} + } + } + + // Unmarshal environment + var environment map[string]string + if row.Environment.Valid && row.Environment.String != "" { + if err := json.Unmarshal([]byte(row.Environment.String), &environment); err != nil { + return nil, fmt.Errorf("failed to unmarshal environment: %w", err) + } + } + + // Convert integers to boolean pointers + autoRestart := row.AutoRestart == 1 + maxRestarts := row.MaxRestarts + restartDelay := row.RestartDelay + onDemandStart := row.OnDemandStart == 1 + idleTimeout := row.IdleTimeout + dockerEnabled := row.DockerEnabled == 1 + + // Create instance options + opts := &instance.Options{ + AutoRestart: &autoRestart, + MaxRestarts: &maxRestarts, + RestartDelay: &restartDelay, + OnDemandStart: &onDemandStart, + IdleTimeout: &idleTimeout, + DockerEnabled: &dockerEnabled, + CommandOverride: row.CommandOverride.String, + Nodes: nodes, + Environment: environment, + BackendOptions: backendOptions, + } + + // Create instance struct and manually unmarshal fields + // We do this manually because BackendOptions and Nodes have json:"-" tags + // and would be lost if we used the marshal/unmarshal cycle + inst := &instance.Instance{ + Name: row.Name, + Created: row.CreatedAt, + } + + // Create a temporary struct for unmarshaling the status and simple fields + type instanceAux struct { + Name string `json:"name"` + Created int64 `json:"created"` + Status string `json:"status"` + Options struct { + AutoRestart *bool `json:"auto_restart,omitempty"` + MaxRestarts *int `json:"max_restarts,omitempty"` + RestartDelay *int `json:"restart_delay,omitempty"` + OnDemandStart *bool `json:"on_demand_start,omitempty"` + IdleTimeout *int `json:"idle_timeout,omitempty"` + DockerEnabled *bool `json:"docker_enabled,omitempty"` + CommandOverride string `json:"command_override,omitempty"` + Environment map[string]string `json:"environment,omitempty"` + } `json:"options"` + } + + aux := instanceAux{ + Name: row.Name, + Created: row.CreatedAt, + Status: row.Status, + } + aux.Options.AutoRestart = opts.AutoRestart + aux.Options.MaxRestarts = opts.MaxRestarts + aux.Options.RestartDelay = opts.RestartDelay + aux.Options.OnDemandStart = opts.OnDemandStart + aux.Options.IdleTimeout = opts.IdleTimeout + aux.Options.DockerEnabled = opts.DockerEnabled + aux.Options.CommandOverride = opts.CommandOverride + aux.Options.Environment = opts.Environment + + instJSON, err := json.Marshal(aux) + if err != nil { + return nil, fmt.Errorf("failed to marshal instance: %w", err) + } + + if err := json.Unmarshal(instJSON, inst); err != nil { + return nil, fmt.Errorf("failed to unmarshal instance: %w", err) + } + + // Manually set the fields that have json:"-" tags by using SetOptions + // We need to set the whole options object because GetOptions returns a copy + // and we need to ensure BackendOptions and Nodes (which have json:"-") are set + inst.SetOptions(opts) + + return inst, nil +} + +// Database interface implementation + +// Save saves an instance to the database (insert or update) +func (db *DB) Save(inst *instance.Instance) error { + ctx := context.Background() + + // Try to get existing instance + existing, err := db.GetByName(ctx, inst.Name) + if err != nil { + // Instance doesn't exist, create it + return db.Create(ctx, inst) + } + + // Instance exists, update it + if existing != nil { + return db.Update(ctx, inst) + } + + return db.Create(ctx, inst) +} + +// Delete removes an instance from the database +func (db *DB) Delete(name string) error { + ctx := context.Background() + return db.DeleteInstance(ctx, name) +} + +// LoadAll loads all instances from the database +func (db *DB) LoadAll() ([]*instance.Instance, error) { + ctx := context.Background() + return db.GetAll(ctx) +} diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go new file mode 100644 index 0000000..476d3a8 --- /dev/null +++ b/pkg/database/migrations.go @@ -0,0 +1,78 @@ +package database + +import ( + "embed" + "fmt" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// RunMigrations applies all pending database migrations +func RunMigrations(db *DB) error { + if db == nil || db.DB == nil { + return fmt.Errorf("database connection is nil") + } + + // Create migration source from embedded files + sourceDriver, err := iofs.New(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("failed to create migration source: %w", err) + } + + // Create database driver for migrations + dbDriver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{}) + if err != nil { + return fmt.Errorf("failed to create database driver: %w", err) + } + + // Create migrator + migrator, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", dbDriver) + if err != nil { + return fmt.Errorf("failed to create migrator: %w", err) + } + + // Get current version + currentVersion, dirty, err := migrator.Version() + if err != nil && err != migrate.ErrNilVersion { + return fmt.Errorf("failed to get current migration version: %w", err) + } + + if dirty { + return fmt.Errorf("database is in dirty state at version %d - manual intervention required", currentVersion) + } + + // Run migrations + log.Printf("Running database migrations (current version: %v)", currentVersionString(currentVersion, err)) + + if err := migrator.Up(); err != nil { + if err == migrate.ErrNoChange { + log.Println("Database schema is up to date") + return nil + } + return fmt.Errorf("failed to run migrations: %w", err) + } + + // Get new version + newVersion, _, err := migrator.Version() + if err != nil { + log.Printf("Migrations completed (unable to determine new version: %v)", err) + } else { + log.Printf("Migrations completed successfully (new version: %d)", newVersion) + } + + return nil +} + +// currentVersionString returns a string representation of the current version +func currentVersionString(version uint, err error) string { + if err == migrate.ErrNilVersion { + return "none" + } + return fmt.Sprintf("%d", version) +} diff --git a/pkg/database/migrations/001_initial_schema.down.sql b/pkg/database/migrations/001_initial_schema.down.sql new file mode 100644 index 0000000..08b26e0 --- /dev/null +++ b/pkg/database/migrations/001_initial_schema.down.sql @@ -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; diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql new file mode 100644 index 0000000..46e1f06 --- /dev/null +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -0,0 +1,43 @@ +-- ----------------------------------------------------------------------------- +-- Instances Table: Central configuration and state for LLM backends +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS instances ( + -- Primary identification + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + + -- Backend configuration + backend_type TEXT NOT NULL CHECK(backend_type IN ('llama_cpp', 'mlx_lm', 'vllm')), + backend_config_json TEXT NOT NULL, -- Backend-specific options (150+ fields for llama_cpp, etc.) + + -- Instance state + status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped', + + -- Timestamps (created_at stored as Unix timestamp for compatibility with existing JSON format) + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + + -- Common instance options (extracted from Instance.Options) + -- NOT NULL with defaults to match config behavior (nil pointers use these defaults) + auto_restart INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable automatic restart on failure + max_restarts INTEGER NOT NULL DEFAULT -1, -- Maximum restart attempts (-1 = unlimited) + restart_delay INTEGER NOT NULL DEFAULT 0, -- Delay between restarts in seconds + on_demand_start INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable on-demand instance start + idle_timeout INTEGER NOT NULL DEFAULT 0, -- Idle timeout in minutes before auto-stop + docker_enabled INTEGER NOT NULL DEFAULT 0, -- Boolean: Run instance in Docker container + command_override TEXT, -- Custom command to override default backend command (nullable) + + -- JSON fields for complex structures (nullable - empty when not set) + nodes TEXT, -- JSON array of node names for remote instances + environment TEXT, -- JSON map of environment variables + + -- Future extensibility hook + owner_user_id TEXT NULL -- Future: OIDC user ID for ownership +); + +-- ----------------------------------------------------------------------------- +-- Indexes for performance +-- ----------------------------------------------------------------------------- +CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); +CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); +CREATE INDEX IF NOT EXISTS idx_instances_backend_type ON instances(backend_type); diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 679474d..2bc5bc8 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "llamactl/pkg/config" + "llamactl/pkg/database" "llamactl/pkg/instance" "log" "sync" @@ -28,11 +29,11 @@ type InstanceManager interface { type instanceManager struct { // Components (each with own synchronization) - registry *instanceRegistry - ports *portAllocator - persistence *instancePersister - remote *remoteManager - lifecycle *lifecycleManager + registry *instanceRegistry + ports *portAllocator + db database.Database + remote *remoteManager + lifecycle *lifecycleManager // Configuration globalConfig *config.AppConfig @@ -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.Database) 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) } diff --git a/pkg/manager/manager_test.go b/pkg/manager/manager_test.go index b03ea12..ba6a109 100644 --- a/pkg/manager/manager_test.go +++ b/pkg/manager/manager_test.go @@ -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) } diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 14b86a7..7256d7f 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -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 diff --git a/pkg/manager/operations_test.go b/pkg/manager/operations_test.go index 870e907..0702191 100644 --- a/pkg/manager/operations_test.go +++ b/pkg/manager/operations_test.go @@ -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) } diff --git a/pkg/manager/persistence.go b/pkg/manager/persistence.go deleted file mode 100644 index a106efe..0000000 --- a/pkg/manager/persistence.go +++ /dev/null @@ -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 -} diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..2d27505 --- /dev/null +++ b/spec.md @@ -0,0 +1,213 @@ +# 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 +``` From 7272aa26ec877a2ebd3251a82a65e9435c3e60f0 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 30 Nov 2025 14:21:04 +0100 Subject: [PATCH 2/6] Refactor database interface and migration functions --- cmd/server/migrate_json.go | 7 +- pkg/database/database.go | 18 +- pkg/database/instances.go | 324 +++--------------- pkg/database/migrations.go | 2 +- .../migrations/001_initial_schema.up.sql | 25 +- pkg/manager/manager.go | 14 +- spec.md | 213 ------------ 7 files changed, 75 insertions(+), 528 deletions(-) delete mode 100644 spec.md diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go index 82a02ac..7ee6a2b 100644 --- a/cmd/server/migrate_json.go +++ b/cmd/server/migrate_json.go @@ -13,10 +13,7 @@ import ( // migrateFromJSON migrates instances from JSON files to SQLite database // This is a one-time migration that runs on first startup with existing JSON files. -// -// TODO: This migration code can be removed in a future version (post-1.0) -// once most users have migrated from JSON to SQLite. -func migrateFromJSON(cfg *config.AppConfig, db database.Database) error { +func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { instancesDir := cfg.Instances.InstancesDir if instancesDir == "" { return nil // No instances directory configured @@ -79,7 +76,7 @@ func migrateFromJSON(cfg *config.AppConfig, db database.Database) error { } // migrateJSONFile migrates a single JSON file to the database -func migrateJSONFile(filename string, db database.Database) error { +func migrateJSONFile(filename string, db database.DB) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read file: %w", err) diff --git a/pkg/database/database.go b/pkg/database/database.go index 516425c..957fac7 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -11,8 +11,8 @@ import ( _ "github.com/mattn/go-sqlite3" ) -// Database defines the interface for instance persistence operations -type Database interface { +// DB defines the interface for instance persistence operations +type DB interface { Save(inst *instance.Instance) error Delete(name string) error LoadAll() ([]*instance.Instance, error) @@ -30,14 +30,14 @@ type Config struct { ConnMaxLifetime time.Duration } -// DB wraps the database connection with configuration -type DB struct { +// sqliteDB wraps the database connection with configuration +type sqliteDB struct { *sql.DB config *Config } // Open creates a new database connection with the provided configuration -func Open(config *Config) (*DB, error) { +func Open(config *Config) (*sqliteDB, error) { if config == nil { return nil, fmt.Errorf("database config cannot be nil") } @@ -56,7 +56,7 @@ func Open(config *Config) (*DB, error) { // Open SQLite database with proper options // - _journal_mode=WAL: Write-Ahead Logging for better concurrency // - _busy_timeout=5000: Wait up to 5 seconds if database is locked - // - _foreign_keys=1: Enable foreign key constraints (for future use) + // - _foreign_keys=1: Enable foreign key constraints dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=1", config.Path) sqlDB, err := sql.Open("sqlite3", dsn) @@ -83,14 +83,14 @@ func Open(config *Config) (*DB, error) { log.Printf("Database connection established: %s", config.Path) - return &DB{ + return &sqliteDB{ DB: sqlDB, config: config, }, nil } // Close closes the database connection -func (db *DB) Close() error { +func (db *sqliteDB) Close() error { if db.DB != nil { log.Println("Closing database connection") return db.DB.Close() @@ -99,7 +99,7 @@ func (db *DB) Close() error { } // HealthCheck verifies the database is accessible -func (db *DB) HealthCheck() error { +func (db *sqliteDB) HealthCheck() error { if db.DB == nil { return fmt.Errorf("database connection is nil") } diff --git a/pkg/database/instances.go b/pkg/database/instances.go index 721ea96..1dbdb32 100644 --- a/pkg/database/instances.go +++ b/pkg/database/instances.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "llamactl/pkg/backends" "llamactl/pkg/instance" "log" "time" @@ -13,27 +12,17 @@ import ( // instanceRow represents a row in the instances table type instanceRow struct { - ID int - Name string - BackendType string - BackendConfigJSON string - Status string - CreatedAt int64 - UpdatedAt int64 - AutoRestart int - MaxRestarts int - RestartDelay int - OnDemandStart int - IdleTimeout int - DockerEnabled int - CommandOverride sql.NullString - Nodes sql.NullString - Environment sql.NullString - OwnerUserID sql.NullString + 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 *DB) Create(ctx context.Context, inst *instance.Instance) error { +func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { if inst == nil { return fmt.Errorf("instance cannot be nil") } @@ -52,20 +41,12 @@ func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { // Insert into database query := ` INSERT INTO instances ( - name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + name, status, created_at, updated_at, options_json, owner_user_id + ) VALUES (?, ?, ?, ?, ?, ?) ` _, err = db.DB.ExecContext(ctx, query, - row.Name, row.BackendType, row.BackendConfigJSON, row.Status, - row.CreatedAt, row.UpdatedAt, - row.AutoRestart, row.MaxRestarts, row.RestartDelay, - row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, - row.CommandOverride, row.Nodes, row.Environment, row.OwnerUserID, + row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID, ) if err != nil { @@ -76,24 +57,16 @@ func (db *DB) Create(ctx context.Context, inst *instance.Instance) error { } // GetByName retrieves an instance by name -func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, error) { +func (db *sqliteDB) GetByName(ctx context.Context, name string) (*instance.Instance, error) { query := ` - SELECT id, name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id + SELECT id, name, status, created_at, updated_at, options_json, owner_user_id FROM instances WHERE name = ? ` var row instanceRow err := db.DB.QueryRowContext(ctx, query, name).Scan( - &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, - &row.CreatedAt, &row.UpdatedAt, - &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, - &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, - &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID, ) if err == sql.ErrNoRows { @@ -107,13 +80,9 @@ func (db *DB) GetByName(ctx context.Context, name string) (*instance.Instance, e } // GetAll retrieves all instances from the database -func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { +func (db *sqliteDB) GetAll(ctx context.Context) ([]*instance.Instance, error) { query := ` - SELECT id, name, backend_type, backend_config_json, status, - created_at, updated_at, - auto_restart, max_restarts, restart_delay, - on_demand_start, idle_timeout, docker_enabled, - command_override, nodes, environment, owner_user_id + SELECT id, name, status, created_at, updated_at, options_json, owner_user_id FROM instances ORDER BY created_at ASC ` @@ -128,11 +97,7 @@ func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { for rows.Next() { var row instanceRow err := rows.Scan( - &row.ID, &row.Name, &row.BackendType, &row.BackendConfigJSON, &row.Status, - &row.CreatedAt, &row.UpdatedAt, - &row.AutoRestart, &row.MaxRestarts, &row.RestartDelay, - &row.OnDemandStart, &row.IdleTimeout, &row.DockerEnabled, - &row.CommandOverride, &row.Nodes, &row.Environment, &row.OwnerUserID, + &row.ID, &row.Name, &row.Status, &row.CreatedAt, &row.UpdatedAt, &row.OptionsJSON, &row.OwnerUserID, ) if err != nil { log.Printf("Failed to scan instance row: %v", err) @@ -156,7 +121,7 @@ func (db *DB) GetAll(ctx context.Context) ([]*instance.Instance, error) { } // Update updates an existing instance -func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { +func (db *sqliteDB) Update(ctx context.Context, inst *instance.Instance) error { if inst == nil { return fmt.Errorf("instance cannot be nil") } @@ -175,21 +140,12 @@ func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { // Update in database query := ` UPDATE instances SET - backend_type = ?, backend_config_json = ?, status = ?, - updated_at = ?, - auto_restart = ?, max_restarts = ?, restart_delay = ?, - on_demand_start = ?, idle_timeout = ?, docker_enabled = ?, - command_override = ?, nodes = ?, environment = ? + status = ?, updated_at = ?, options_json = ? WHERE name = ? ` result, err := db.DB.ExecContext(ctx, query, - row.BackendType, row.BackendConfigJSON, row.Status, - row.UpdatedAt, - row.AutoRestart, row.MaxRestarts, row.RestartDelay, - row.OnDemandStart, row.IdleTimeout, row.DockerEnabled, - row.CommandOverride, row.Nodes, row.Environment, - row.Name, + row.Status, row.UpdatedAt, row.OptionsJSON, row.Name, ) if err != nil { @@ -209,7 +165,7 @@ func (db *DB) Update(ctx context.Context, inst *instance.Instance) error { } // UpdateStatus updates only the status of an instance (optimized operation) -func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Status) error { +func (db *sqliteDB) UpdateStatus(ctx context.Context, name string, status instance.Status) error { // Convert status to string statusJSON, err := status.MarshalJSON() if err != nil { @@ -245,7 +201,7 @@ func (db *DB) UpdateStatus(ctx context.Context, name string, status instance.Sta } // DeleteInstance removes an instance from the database -func (db *DB) DeleteInstance(ctx context.Context, name string) error { +func (db *sqliteDB) DeleteInstance(ctx context.Context, name string) error { query := `DELETE FROM instances WHERE name = ?` result, err := db.DB.ExecContext(ctx, query, name) @@ -266,94 +222,18 @@ func (db *DB) DeleteInstance(ctx context.Context, name string) error { } // instanceToRow converts an Instance to a database row -func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { +func (db *sqliteDB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { opts := inst.GetOptions() if opts == nil { return nil, fmt.Errorf("instance options cannot be nil") } - // Marshal backend options to JSON (this uses the MarshalJSON method which handles typed backends) - backendJSON, err := json.Marshal(&opts.BackendOptions) + // Marshal options to JSON using the existing MarshalJSON method + optionsJSON, err := json.Marshal(opts) if err != nil { - return nil, fmt.Errorf("failed to marshal backend options: %w", err) + return nil, fmt.Errorf("failed to marshal options: %w", err) } - // Extract just the backend_options field from the marshaled JSON - var backendWrapper struct { - BackendOptions map[string]any `json:"backend_options"` - } - if err := json.Unmarshal(backendJSON, &backendWrapper); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend wrapper: %w", err) - } - - backendConfigJSON, err := json.Marshal(backendWrapper.BackendOptions) - if err != nil { - return nil, fmt.Errorf("failed to marshal backend config: %w", err) - } - - // Convert nodes map to JSON array - var nodesJSON sql.NullString - if len(opts.Nodes) > 0 { - nodesList := make([]string, 0, len(opts.Nodes)) - for node := range opts.Nodes { - nodesList = append(nodesList, node) - } - nodesBytes, err := json.Marshal(nodesList) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodes: %w", err) - } - nodesJSON = sql.NullString{String: string(nodesBytes), Valid: true} - } - - // Convert environment map to JSON - var envJSON sql.NullString - if len(opts.Environment) > 0 { - envBytes, err := json.Marshal(opts.Environment) - if err != nil { - return nil, fmt.Errorf("failed to marshal environment: %w", err) - } - envJSON = sql.NullString{String: string(envBytes), Valid: true} - } - - // Convert command override - var cmdOverride sql.NullString - if opts.CommandOverride != "" { - cmdOverride = sql.NullString{String: opts.CommandOverride, Valid: true} - } - - // Convert boolean pointers to integers (0 or 1) - autoRestart := 0 - if opts.AutoRestart != nil && *opts.AutoRestart { - autoRestart = 1 - } - - maxRestarts := -1 - if opts.MaxRestarts != nil { - maxRestarts = *opts.MaxRestarts - } - - restartDelay := 0 - if opts.RestartDelay != nil { - restartDelay = *opts.RestartDelay - } - - onDemandStart := 0 - if opts.OnDemandStart != nil && *opts.OnDemandStart { - onDemandStart = 1 - } - - idleTimeout := 0 - if opts.IdleTimeout != nil { - idleTimeout = *opts.IdleTimeout - } - - dockerEnabled := 0 - if opts.DockerEnabled != nil && *opts.DockerEnabled { - dockerEnabled = 1 - } - - now := time.Now().Unix() - // Convert status to string statusJSON, err := inst.GetStatus().MarshalJSON() if err != nil { @@ -365,150 +245,50 @@ func (db *DB) instanceToRow(inst *instance.Instance) (*instanceRow, error) { } return &instanceRow{ - Name: inst.Name, - BackendType: string(opts.BackendOptions.BackendType), - BackendConfigJSON: string(backendConfigJSON), - Status: statusStr, - CreatedAt: inst.Created, - UpdatedAt: now, - AutoRestart: autoRestart, - MaxRestarts: maxRestarts, - RestartDelay: restartDelay, - OnDemandStart: onDemandStart, - IdleTimeout: idleTimeout, - DockerEnabled: dockerEnabled, - CommandOverride: cmdOverride, - Nodes: nodesJSON, - Environment: envJSON, + 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 *DB) rowToInstance(row *instanceRow) (*instance.Instance, error) { - // Unmarshal backend config - var backendConfig map[string]any - if err := json.Unmarshal([]byte(row.BackendConfigJSON), &backendConfig); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend config: %w", err) +func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) { + // Unmarshal options from JSON using the existing UnmarshalJSON method + var opts instance.Options + if err := json.Unmarshal([]byte(row.OptionsJSON), &opts); err != nil { + return nil, fmt.Errorf("failed to unmarshal options: %w", err) } - // Create backends.Options by marshaling and unmarshaling to trigger the UnmarshalJSON logic - // This ensures the typed backend fields (LlamaServerOptions, VllmServerOptions, etc.) are populated - var backendOptions backends.Options - backendJSON, err := json.Marshal(map[string]any{ - "backend_type": row.BackendType, - "backend_options": backendConfig, + // Build complete instance JSON with all fields + instanceJSON, err := json.Marshal(map[string]any{ + "name": row.Name, + "created": row.CreatedAt, + "status": row.Status, + "options": json.RawMessage(row.OptionsJSON), }) - if err != nil { - return nil, fmt.Errorf("failed to marshal backend for unmarshaling: %w", err) - } - - if err := json.Unmarshal(backendJSON, &backendOptions); err != nil { - return nil, fmt.Errorf("failed to unmarshal backend options: %w", err) - } - - // Unmarshal nodes - var nodes map[string]struct{} - if row.Nodes.Valid && row.Nodes.String != "" { - var nodesList []string - if err := json.Unmarshal([]byte(row.Nodes.String), &nodesList); err != nil { - return nil, fmt.Errorf("failed to unmarshal nodes: %w", err) - } - nodes = make(map[string]struct{}, len(nodesList)) - for _, node := range nodesList { - nodes[node] = struct{}{} - } - } - - // Unmarshal environment - var environment map[string]string - if row.Environment.Valid && row.Environment.String != "" { - if err := json.Unmarshal([]byte(row.Environment.String), &environment); err != nil { - return nil, fmt.Errorf("failed to unmarshal environment: %w", err) - } - } - - // Convert integers to boolean pointers - autoRestart := row.AutoRestart == 1 - maxRestarts := row.MaxRestarts - restartDelay := row.RestartDelay - onDemandStart := row.OnDemandStart == 1 - idleTimeout := row.IdleTimeout - dockerEnabled := row.DockerEnabled == 1 - - // Create instance options - opts := &instance.Options{ - AutoRestart: &autoRestart, - MaxRestarts: &maxRestarts, - RestartDelay: &restartDelay, - OnDemandStart: &onDemandStart, - IdleTimeout: &idleTimeout, - DockerEnabled: &dockerEnabled, - CommandOverride: row.CommandOverride.String, - Nodes: nodes, - Environment: environment, - BackendOptions: backendOptions, - } - - // Create instance struct and manually unmarshal fields - // We do this manually because BackendOptions and Nodes have json:"-" tags - // and would be lost if we used the marshal/unmarshal cycle - inst := &instance.Instance{ - Name: row.Name, - Created: row.CreatedAt, - } - - // Create a temporary struct for unmarshaling the status and simple fields - type instanceAux struct { - Name string `json:"name"` - Created int64 `json:"created"` - Status string `json:"status"` - Options struct { - AutoRestart *bool `json:"auto_restart,omitempty"` - MaxRestarts *int `json:"max_restarts,omitempty"` - RestartDelay *int `json:"restart_delay,omitempty"` - OnDemandStart *bool `json:"on_demand_start,omitempty"` - IdleTimeout *int `json:"idle_timeout,omitempty"` - DockerEnabled *bool `json:"docker_enabled,omitempty"` - CommandOverride string `json:"command_override,omitempty"` - Environment map[string]string `json:"environment,omitempty"` - } `json:"options"` - } - - aux := instanceAux{ - Name: row.Name, - Created: row.CreatedAt, - Status: row.Status, - } - aux.Options.AutoRestart = opts.AutoRestart - aux.Options.MaxRestarts = opts.MaxRestarts - aux.Options.RestartDelay = opts.RestartDelay - aux.Options.OnDemandStart = opts.OnDemandStart - aux.Options.IdleTimeout = opts.IdleTimeout - aux.Options.DockerEnabled = opts.DockerEnabled - aux.Options.CommandOverride = opts.CommandOverride - aux.Options.Environment = opts.Environment - - instJSON, err := json.Marshal(aux) if err != nil { return nil, fmt.Errorf("failed to marshal instance: %w", err) } - if err := json.Unmarshal(instJSON, inst); err != nil { + // Unmarshal into a complete Instance + var inst instance.Instance + if err := json.Unmarshal(instanceJSON, &inst); err != nil { return nil, fmt.Errorf("failed to unmarshal instance: %w", err) } - // Manually set the fields that have json:"-" tags by using SetOptions - // We need to set the whole options object because GetOptions returns a copy - // and we need to ensure BackendOptions and Nodes (which have json:"-") are set - inst.SetOptions(opts) + // The UnmarshalJSON doesn't handle BackendOptions and Nodes (they have json:"-" tags) + // So we need to explicitly set the options again to ensure they're properly set + inst.SetOptions(&opts) - return inst, nil + return &inst, nil } // Database interface implementation // Save saves an instance to the database (insert or update) -func (db *DB) Save(inst *instance.Instance) error { +func (db *sqliteDB) Save(inst *instance.Instance) error { ctx := context.Background() // Try to get existing instance @@ -527,13 +307,13 @@ func (db *DB) Save(inst *instance.Instance) error { } // Delete removes an instance from the database -func (db *DB) Delete(name string) error { +func (db *sqliteDB) Delete(name string) error { ctx := context.Background() return db.DeleteInstance(ctx, name) } // LoadAll loads all instances from the database -func (db *DB) LoadAll() ([]*instance.Instance, error) { +func (db *sqliteDB) LoadAll() ([]*instance.Instance, error) { ctx := context.Background() return db.GetAll(ctx) } diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index 476d3a8..34fb1bb 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -14,7 +14,7 @@ import ( var migrationFiles embed.FS // RunMigrations applies all pending database migrations -func RunMigrations(db *DB) error { +func RunMigrations(db *sqliteDB) error { if db == nil || db.DB == nil { return fmt.Errorf("database connection is nil") } diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 46e1f06..ddb229c 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -6,10 +6,6 @@ CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - -- Backend configuration - backend_type TEXT NOT NULL CHECK(backend_type IN ('llama_cpp', 'mlx_lm', 'vllm')), - backend_config_json TEXT NOT NULL, -- Backend-specific options (150+ fields for llama_cpp, etc.) - -- Instance state status TEXT NOT NULL CHECK(status IN ('stopped', 'running', 'failed', 'restarting', 'shutting_down')) DEFAULT 'stopped', @@ -17,27 +13,14 @@ CREATE TABLE IF NOT EXISTS instances ( created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - -- Common instance options (extracted from Instance.Options) - -- NOT NULL with defaults to match config behavior (nil pointers use these defaults) - auto_restart INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable automatic restart on failure - max_restarts INTEGER NOT NULL DEFAULT -1, -- Maximum restart attempts (-1 = unlimited) - restart_delay INTEGER NOT NULL DEFAULT 0, -- Delay between restarts in seconds - on_demand_start INTEGER NOT NULL DEFAULT 0, -- Boolean: Enable on-demand instance start - idle_timeout INTEGER NOT NULL DEFAULT 0, -- Idle timeout in minutes before auto-stop - docker_enabled INTEGER NOT NULL DEFAULT 0, -- Boolean: Run instance in Docker container - command_override TEXT, -- Custom command to override default backend command (nullable) + -- All instance options stored as a single JSON blob + options_json TEXT NOT NULL, - -- JSON fields for complex structures (nullable - empty when not set) - nodes TEXT, -- JSON array of node names for remote instances - environment TEXT, -- JSON map of environment variables - - -- Future extensibility hook - owner_user_id TEXT NULL -- Future: OIDC user ID for ownership -); + -- Future: OIDC user ID for ownership + owner_user_id TEXT NULL -- ----------------------------------------------------------------------------- -- Indexes for performance -- ----------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); -CREATE INDEX IF NOT EXISTS idx_instances_backend_type ON instances(backend_type); diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 2bc5bc8..5aca037 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -29,11 +29,11 @@ type InstanceManager interface { type instanceManager struct { // Components (each with own synchronization) - registry *instanceRegistry - ports *portAllocator - db database.Database - remote *remoteManager - lifecycle *lifecycleManager + registry *instanceRegistry + ports *portAllocator + db database.DB + remote *remoteManager + lifecycle *lifecycleManager // Configuration globalConfig *config.AppConfig @@ -44,7 +44,7 @@ type instanceManager struct { } // New creates a new instance of InstanceManager with dependency injection. -func New(globalConfig *config.AppConfig, db database.Database) InstanceManager { +func New(globalConfig *config.AppConfig, db database.DB) InstanceManager { if globalConfig.Instances.TimeoutCheckInterval <= 0 { globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set @@ -259,7 +259,7 @@ func (im *instanceManager) autoStartInstances() { } } -func (im *instanceManager) onStatusChange(name string, oldStatus, newStatus instance.Status) { +func (im *instanceManager) onStatusChange(name string, _, newStatus instance.Status) { if newStatus == instance.Running { im.registry.markRunning(name) } else { diff --git a/spec.md b/spec.md deleted file mode 100644 index 2d27505..0000000 --- a/spec.md +++ /dev/null @@ -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 -``` From 00114caa00cc7f107cde26e7949ff8aa97c90e78 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 1 Dec 2025 20:48:17 +0100 Subject: [PATCH 3/6] Add db config and move data dir config --- README.md | 13 ++++-- cmd/server/main.go | 11 ++++- docs/configuration.md | 85 +++++++++++++++++++++++++++------------ pkg/config/config.go | 56 +++++++++++++------------- webui/src/types/config.ts | 10 ++++- 5 files changed, 117 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 9f2039b..13d9b8e 100644 --- a/README.md +++ b/README.md @@ -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: instances # Instance configs directory (relative to data_dir) + logs_dir: logs # Logs directory (relative to data_dir) 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: llamactl.db # Database file path (relative to data_dir) + 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 diff --git a/cmd/server/main.go b/cmd/server/main.go index ffa0c27..0eeca45 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,10 +50,17 @@ 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) } diff --git a/docs/configuration.md b/docs/configuration.md index c271f29..1455cdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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,32 +200,44 @@ 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) - 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) - enable_lru_eviction: true # Enable LRU eviction for idle instances - default_auto_restart: true # Default auto-restart setting - default_max_restarts: 3 # Default maximum restart attempts - default_restart_delay: 5 # Default restart delay in seconds - default_on_demand_start: true # Default on-demand start setting - on_demand_start_timeout: 120 # Default on-demand start timeout in seconds - timeout_check_interval: 5 # Default instance timeout check interval in minutes + port_range: [8000, 9000] # Port range for instances (default: [8000, 9000]) + 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) + enable_lru_eviction: true # Enable LRU eviction for idle instances + default_auto_restart: true # Default auto-restart setting + default_max_restarts: 3 # Default maximum restart attempts + default_restart_delay: 5 # Default restart delay in seconds + default_on_demand_start: true # Default on-demand start setting + on_demand_start_timeout: 120 # Default on-demand start timeout in seconds + timeout_check_interval: 5 # Default instance timeout check interval in minutes ``` -**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) +**Environment Variables:** +- `LLAMACTL_INSTANCE_PORT_RANGE` - Port range (format: "8000-9000" or "8000,9000") +- `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) - `LLAMACTL_MAX_INSTANCES` - Maximum number of instances - `LLAMACTL_MAX_RUNNING_INSTANCES` - Maximum number of running instances - `LLAMACTL_ENABLE_LRU_EVICTION` - Enable LRU eviction for idle instances @@ -226,8 +245,24 @@ instances: - `LLAMACTL_DEFAULT_MAX_RESTARTS` - Default maximum restarts - `LLAMACTL_DEFAULT_RESTART_DELAY` - Default restart delay in seconds - `LLAMACTL_DEFAULT_ON_DEMAND_START` - Default on-demand start setting (true/false) -- `LLAMACTL_ON_DEMAND_START_TIMEOUT` - Default on-demand start timeout in seconds -- `LLAMACTL_TIMEOUT_CHECK_INTERVAL` - Default instance timeout check interval in minutes +- `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 diff --git a/pkg/config/config.go b/pkg/config/config.go index b9b9ec8..1c53c17 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,16 +40,20 @@ type BackendConfig struct { // AppConfig represents the configuration for llamactl 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"` - Version string `yaml:"-" json:"version"` - CommitHash string `yaml:"-" json:"commit_hash"` - BuildTime string `yaml:"-" json:"build_time"` + 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"` } // ServerConfig contains HTTP server configuration @@ -75,7 +79,7 @@ type ServerConfig struct { // DatabaseConfig contains database configuration settings type DatabaseConfig struct { - // Database file path (relative to data_dir or absolute) + // Database file path (relative to the top-level data_dir or absolute) Path string `yaml:"path" json:"path"` // Connection settings @@ -89,13 +93,11 @@ 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 @@ -156,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", @@ -166,6 +170,7 @@ func LoadConfig(configPath string) (AppConfig, error) { }, LocalNode: "main", Nodes: map[string]NodeConfig{}, + DataDir: defaultDataDir, Backends: BackendConfig{ LlamaCpp: BackendSettings{ Command: "llama-server", @@ -176,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{}, }, }, @@ -188,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{}, }, @@ -201,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: "", @@ -218,7 +222,7 @@ func LoadConfig(configPath string) (AppConfig, error) { TimeoutCheckInterval: 5, // Check timeouts every 5 minutes }, Database: DatabaseConfig{ - Path: "llamactl.db", // Relative to data_dir + Path: "", // Will be set to data_dir/llamactl.db if empty MaxOpenConnections: 25, MaxIdleConnections: 5, ConnMaxLifetime: 5 * time.Minute, @@ -244,17 +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") } - - // Resolve database path relative to DataDir if it's not absolute - if cfg.Database.Path != "" && !filepath.IsAbs(cfg.Database.Path) { - cfg.Database.Path = filepath.Join(cfg.Instances.DataDir, cfg.Database.Path) + if cfg.Database.Path == "" { + cfg.Database.Path = filepath.Join(cfg.DataDir, "llamactl.db") } // Validate port range @@ -312,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 diff --git a/webui/src/types/config.ts b/webui/src/types/config.ts index 21f15fa..8a99a47 100644 --- a/webui/src/types/config.ts +++ b/webui/src/types/config.ts @@ -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 + data_dir: string version?: string commit_hash?: string build_time?: string From 7c05fd278ccf2aa49311d0b50bf79bba250452a1 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 2 Dec 2025 20:07:39 +0100 Subject: [PATCH 4/6] Update configuration configs and logs paths --- README.md | 34 +++++++++++++++++----------------- docs/configuration.md | 26 +++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 13d9b8e..405cc69 100644 --- a/README.md +++ b/README.md @@ -182,25 +182,25 @@ backends: data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS instances: - port_range: [8000, 9000] # Port range for instances - configs_dir: instances # Instance configs directory (relative to data_dir) - logs_dir: logs # Logs directory (relative to data_dir) - 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) - enable_lru_eviction: true # Enable LRU eviction for idle instances - default_auto_restart: true # Auto-restart new instances by default - default_max_restarts: 3 # Max restarts for new instances - default_restart_delay: 5 # Restart delay (seconds) for new instances - default_on_demand_start: true # Default on-demand start setting - on_demand_start_timeout: 120 # Default on-demand start timeout in seconds - timeout_check_interval: 5 # Idle instance timeout check in minutes + port_range: [8000, 9000] # Port range for instances + 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) + enable_lru_eviction: true # Enable LRU eviction for idle instances + default_auto_restart: true # Auto-restart new instances by default + default_max_restarts: 3 # Max restarts for new instances + default_restart_delay: 5 # Restart delay (seconds) for new instances + default_on_demand_start: true # Default on-demand start setting + on_demand_start_timeout: 120 # Default on-demand start timeout in seconds + timeout_check_interval: 5 # Idle instance timeout check in minutes database: - path: llamactl.db # Database file path (relative to data_dir) - max_open_connections: 25 # Maximum open database connections - max_idle_connections: 5 # Maximum idle database connections - connection_max_lifetime: 5m # Connection max lifetime + 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 diff --git a/docs/configuration.md b/docs/configuration.md index 1455cdd..85b38b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,19 +52,19 @@ backends: data_dir: ~/.local/share/llamactl # Main data directory (database, instances, logs), default varies by OS instances: - port_range: [8000, 9000] # Port range for instances - 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) - enable_lru_eviction: true # Enable LRU eviction for idle instances - default_auto_restart: true # Auto-restart new instances by default - default_max_restarts: 3 # Max restarts for new instances - default_restart_delay: 5 # Restart delay (seconds) for new instances - default_on_demand_start: true # Default on-demand start setting - on_demand_start_timeout: 120 # Default on-demand start timeout in seconds - timeout_check_interval: 5 # Idle instance timeout check in minutes + port_range: [8000, 9000] # Port range for instances + 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) + enable_lru_eviction: true # Enable LRU eviction for idle instances + default_auto_restart: true # Auto-restart new instances by default + default_max_restarts: 3 # Max restarts for new instances + default_restart_delay: 5 # Restart delay (seconds) for new instances + default_on_demand_start: true # Default on-demand start setting + 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 From 645aa63186f2b045b8e1d768d5045d1253a14746 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 2 Dec 2025 20:38:06 +0100 Subject: [PATCH 5/6] Fix vscode launch params --- .vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b45882e..12a8525 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" From 3fd597638b7dc3c919a22dc43c034cdc1e2c20b9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 2 Dec 2025 20:38:17 +0100 Subject: [PATCH 6/6] Fix migration --- pkg/database/migrations/001_initial_schema.up.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index ddb229c..89eac83 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS instances ( -- Future: OIDC user ID for ownership owner_user_id TEXT NULL +); -- ----------------------------------------------------------------------------- -- Indexes for performance