From e285be2bf3f841dbc1d540cb13ad384bdf62bb07 Mon Sep 17 00:00:00 2001 From: Evgeny Snitko Date: Sun, 12 Apr 2026 12:42:58 +0200 Subject: [PATCH] sqlite --- README.MD | 6 +- docs/configuration.md | 21 ++- examples/example-config.yaml | 11 +- src/go.mod | 10 +- src/go.sum | 17 ++ src/lib/config/config.go | 24 ++- src/lib/config/config_test.go | 8 +- .../repositories/sqliteRestarterRepository.go | 63 +++++++ .../repositories/sqliteTrayRepository.go | 177 ++++++++++++++++++ src/server/server.go | 108 ++++++++--- 10 files changed, 398 insertions(+), 47 deletions(-) create mode 100644 src/lib/restarter/repositories/sqliteRestarterRepository.go create mode 100644 src/lib/trays/repositories/sqliteTrayRepository.go diff --git a/README.MD b/README.MD index 1e6d967..afdefd6 100644 --- a/README.MD +++ b/README.MD @@ -42,9 +42,9 @@ Stale trays (no heartbeat for 2+ minutes) are automatically cleaned up. ## Prerequisites - Go 1.25+ -- MongoDB - A [GitHub App](https://docs.github.com/en/apps/creating-github-apps) with Actions read/write permissions, installed on your organization - Docker and/or GCP credentials depending on your provider choice +- A database: **SQLite** (no external dependencies) or **MongoDB** ## Quick start @@ -71,8 +71,8 @@ server: advertiseUrl: https://cattery.example.com database: - uri: mongodb://localhost:27017/ - database: cattery + type: sqlite + path: /var/lib/cattery/cattery.db github: - name: my-org diff --git a/docs/configuration.md b/docs/configuration.md index 04cacbc..00403f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,9 +12,16 @@ server: advertiseUrl: https://example.org agentSecret: my-secret-token +# SQLite (default): database: - uri: mongodb://localhost:27017/ - database: cattery + type: sqlite + path: /var/lib/cattery/cattery.db + +# Or MongoDB: +# database: +# type: mongodb +# uri: mongodb://localhost:27017/ +# database: cattery github: - name: my-org @@ -74,10 +81,12 @@ trayTypes: #### database -| Key | Type | Required | Description | -|----------|--------|----------|---------------------------------------------------------------| -| uri | string | yes | MongoDB connection string (e.g., mongodb://localhost:27017/). | -| database | string | yes | Database name (e.g., cattery). | +| Key | Type | Required | Description | +|----------|--------|-------------------|----------------------------------------------------------------------| +| type | string | no | Database backend: `sqlite` (default) or `mongodb`. | +| path | string | yes (for sqlite) | Path to the SQLite database file (e.g., /var/lib/cattery/cattery.db) | +| uri | string | yes (for mongodb) | MongoDB connection string (e.g., mongodb://localhost:27017/). | +| database | string | yes (for mongodb) | MongoDB database name (e.g., cattery). | #### github A list of GitHub organizations/accounts the server manages via a GitHub App. diff --git a/examples/example-config.yaml b/examples/example-config.yaml index f18917a..2dee601 100644 --- a/examples/example-config.yaml +++ b/examples/example-config.yaml @@ -2,9 +2,16 @@ server: listenAddress: "0.0.0.0:5137" advertiseUrl: https://cattery.my-org.com +# SQLite (default, zero external dependencies): database: - uri: mongodb://localhost:27017/cattery - database: cattery + type: sqlite + path: /var/lib/cattery/cattery.db + +# MongoDB (uncomment to use instead of SQLite): +# database: +# type: mongodb +# uri: mongodb://localhost:27017/cattery +# database: cattery github: - name: paritytech-stg diff --git a/src/go.mod b/src/go.mod index b3e7782..4b76fe5 100644 --- a/src/go.mod +++ b/src/go.mod @@ -27,6 +27,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -45,12 +46,15 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -71,7 +75,7 @@ require ( golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect @@ -79,4 +83,8 @@ require ( google.golang.org/grpc v1.78.0 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.2 // indirect ) diff --git a/src/go.sum b/src/go.sum index 4fff80a..7886500 100644 --- a/src/go.sum +++ b/src/go.sum @@ -21,6 +21,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -89,6 +91,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -102,6 +106,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -181,8 +187,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -217,3 +226,11 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= +modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/src/lib/config/config.go b/src/lib/config/config.go index 56a79ab..7394853 100644 --- a/src/lib/config/config.go +++ b/src/lib/config/config.go @@ -94,6 +94,24 @@ func LoadConfig(configPath *string) (*CatteryConfig, error) { return nil, fmt.Errorf("failed to unmarshal config file: %w", err) } + switch cfg.Database.Type { + case "": + return nil, fmt.Errorf("database.type is required (supported: sqlite, mongodb)") + case "mongodb": + if cfg.Database.Uri == "" { + return nil, fmt.Errorf("database.uri is required for mongodb") + } + if cfg.Database.Database == "" { + return nil, fmt.Errorf("database.database is required for mongodb") + } + case "sqlite": + if cfg.Database.Path == "" { + return nil, fmt.Errorf("database.path is required for sqlite") + } + default: + return nil, fmt.Errorf("unsupported database type: %s", cfg.Database.Type) + } + cfg.githubMap = make(map[string]*GitHubOrganization) for _, organization := range cfg.Github { cfg.githubMap[organization.Name] = organization @@ -185,8 +203,10 @@ type ServerConfig struct { } type DatabaseConfig struct { - Uri string `yaml:"uri" validate:"required"` - Database string `yaml:"database" validate:"required"` + Type string `yaml:"type"` // "mongodb" (default) or "sqlite" + Uri string `yaml:"uri"` + Database string `yaml:"database"` + Path string `yaml:"path"` // SQLite file path } type GitHubOrganization struct { diff --git a/src/lib/config/config_test.go b/src/lib/config/config_test.go index f69597f..921881e 100644 --- a/src/lib/config/config_test.go +++ b/src/lib/config/config_test.go @@ -23,6 +23,7 @@ server: listenAddress: ":8080" advertiseUrl: "http://localhost:8080" database: + type: mongodb uri: "mongodb://localhost:27017" database: "cattery" github: @@ -113,8 +114,8 @@ server: listenAddress: ":8080" # Missing advertiseUrl database: - uri: "mongodb://localhost:27017" - database: "cattery" + type: sqlite + path: ":memory:" # Missing github section providers: - name: "docker" @@ -313,6 +314,7 @@ server: listenAddress: ":8080" advertiseUrl: "http://localhost:8080" database: + type: mongodb uri: "mongodb://localhost:27017" database: "cattery" github: @@ -371,6 +373,7 @@ server: listenAddress: ":8080" advertiseUrl: "http://localhost:8080" database: + type: mongodb uri: "mongodb://localhost:27017" database: "cattery" github: @@ -413,6 +416,7 @@ server: advertiseUrl: "http://localhost:8080" agentSecret: "my-secret-token" database: + type: mongodb uri: "mongodb://localhost:27017" database: "cattery" github: diff --git a/src/lib/restarter/repositories/sqliteRestarterRepository.go b/src/lib/restarter/repositories/sqliteRestarterRepository.go new file mode 100644 index 0000000..54571d0 --- /dev/null +++ b/src/lib/restarter/repositories/sqliteRestarterRepository.go @@ -0,0 +1,63 @@ +package repositories + +import ( + "context" + "database/sql" + "time" +) + +type SqliteRestarterRepository struct { + db *sql.DB +} + +func NewSqliteRestarterRepository(db *sql.DB) (*SqliteRestarterRepository, error) { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS restart_requests ( + workflow_run_id INTEGER PRIMARY KEY, + org_name TEXT NOT NULL, + repo_name TEXT NOT NULL, + created_at TEXT NOT NULL + )`) + if err != nil { + return nil, err + } + return &SqliteRestarterRepository{db: db}, nil +} + +func (s *SqliteRestarterRepository) SaveRestartRequest(ctx context.Context, workflowRunId int64, orgName string, repoName string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO restart_requests (workflow_run_id, org_name, repo_name, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflow_run_id) DO UPDATE SET + org_name = excluded.org_name, + repo_name = excluded.repo_name, + created_at = excluded.created_at`, + workflowRunId, orgName, repoName, time.Now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *SqliteRestarterRepository) DeleteRestartRequest(ctx context.Context, workflowRunId int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM restart_requests WHERE workflow_run_id = ?`, workflowRunId) + return err +} + +func (s *SqliteRestarterRepository) GetAllPendingRestartRequests(ctx context.Context) ([]RestartRequest, error) { + rows, err := s.db.QueryContext(ctx, `SELECT workflow_run_id, org_name, repo_name, created_at FROM restart_requests`) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []RestartRequest + for rows.Next() { + var r RestartRequest + var createdAt string + err := rows.Scan(&r.WorkflowRunId, &r.OrgName, &r.RepoName, &createdAt) + if err != nil { + return nil, err + } + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + result = append(result, r) + } + return result, rows.Err() +} diff --git a/src/lib/trays/repositories/sqliteTrayRepository.go b/src/lib/trays/repositories/sqliteTrayRepository.go new file mode 100644 index 0000000..7e126f6 --- /dev/null +++ b/src/lib/trays/repositories/sqliteTrayRepository.go @@ -0,0 +1,177 @@ +package repositories + +import ( + "cattery/lib/trays" + "context" + "database/sql" + "encoding/json" + "time" +) + +type SqliteTrayRepository struct { + db *sql.DB +} + +func NewSqliteTrayRepository(db *sql.DB) (*SqliteTrayRepository, error) { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS trays ( + id TEXT PRIMARY KEY, + tray_type_name TEXT NOT NULL, + provider_name TEXT NOT NULL, + github_org_name TEXT NOT NULL, + github_runner_id INTEGER NOT NULL DEFAULT 0, + job_run_id INTEGER NOT NULL DEFAULT 0, + workflow_run_id INTEGER NOT NULL DEFAULT 0, + repository TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL, + status_changed TEXT NOT NULL, + provider_data TEXT NOT NULL DEFAULT '{}' + )`) + if err != nil { + return nil, err + } + return &SqliteTrayRepository{db: db}, nil +} + +func (s *SqliteTrayRepository) GetById(ctx context.Context, trayId string) (*trays.Tray, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, tray_type_name, provider_name, github_org_name, github_runner_id, + job_run_id, workflow_run_id, repository, status, status_changed, provider_data + FROM trays WHERE id = ?`, trayId) + return scanTray(row) +} + +func (s *SqliteTrayRepository) List(ctx context.Context) ([]*trays.Tray, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tray_type_name, provider_name, github_org_name, github_runner_id, + job_run_id, workflow_run_id, repository, status, status_changed, provider_data + FROM trays ORDER BY status_changed DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTrays(rows) +} + +func (s *SqliteTrayRepository) Save(ctx context.Context, tray *trays.Tray) error { + tray.StatusChanged = time.Now().UTC() + providerData, err := json.Marshal(tray.ProviderData) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO trays (id, tray_type_name, provider_name, github_org_name, github_runner_id, + job_run_id, workflow_run_id, repository, status, status_changed, provider_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tray.Id, tray.TrayTypeName, tray.ProviderName, tray.GitHubOrgName, tray.GitHubRunnerId, + tray.JobRunId, tray.WorkflowRunId, tray.Repository, int(tray.Status), + tray.StatusChanged.Format(time.RFC3339Nano), string(providerData)) + return err +} + +func (s *SqliteTrayRepository) Delete(ctx context.Context, trayId string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM trays WHERE id = ?`, trayId) + return err +} + +func (s *SqliteTrayRepository) UpdateStatus(ctx context.Context, trayId string, status trays.TrayStatus, jobRunId int64, workflowRunId int64, ghRunnerId int64, repository string) (*trays.Tray, error) { + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339Nano) + + result, err := s.db.ExecContext(ctx, + `UPDATE trays SET + status = ?, + status_changed = ?, + job_run_id = CASE WHEN ? != 0 THEN ? ELSE job_run_id END, + workflow_run_id = CASE WHEN ? != 0 THEN ? ELSE workflow_run_id END, + github_runner_id = CASE WHEN ? != 0 THEN ? ELSE github_runner_id END, + repository = CASE WHEN ? != '' THEN ? ELSE repository END + WHERE id = ?`, + int(status), nowStr, + jobRunId, jobRunId, + workflowRunId, workflowRunId, + ghRunnerId, ghRunnerId, + repository, repository, + trayId) + if err != nil { + return nil, err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return nil, nil + } + return s.GetById(ctx, trayId) +} + +func (s *SqliteTrayRepository) CountActive(ctx context.Context, trayType string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM trays WHERE tray_type_name = ? AND status != ?`, + trayType, int(trays.TrayStatusDeleting)).Scan(&count) + return count, err +} + +func (s *SqliteTrayRepository) GetStale(ctx context.Context, d time.Duration) ([]*trays.Tray, error) { + cutoff := time.Now().UTC().Add(-d).Format(time.RFC3339Nano) + rows, err := s.db.QueryContext(ctx, + `SELECT id, tray_type_name, provider_name, github_org_name, github_runner_id, + job_run_id, workflow_run_id, repository, status, status_changed, provider_data + FROM trays WHERE status != ? AND status_changed <= ?`, + int(trays.TrayStatusRunning), cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTrays(rows) +} + +// scanTray reads a single tray from a *sql.Row. +func scanTray(row *sql.Row) (*trays.Tray, error) { + var t trays.Tray + var statusInt int + var statusChanged string + var providerDataJSON string + + err := row.Scan(&t.Id, &t.TrayTypeName, &t.ProviderName, &t.GitHubOrgName, + &t.GitHubRunnerId, &t.JobRunId, &t.WorkflowRunId, &t.Repository, + &statusInt, &statusChanged, &providerDataJSON) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + t.Status = trays.TrayStatus(statusInt) + t.StatusChanged, _ = time.Parse(time.RFC3339Nano, statusChanged) + _ = json.Unmarshal([]byte(providerDataJSON), &t.ProviderData) + if t.ProviderData == nil { + t.ProviderData = make(map[string]string) + } + return &t, nil +} + +// scanTrays reads multiple trays from *sql.Rows. +func scanTrays(rows *sql.Rows) ([]*trays.Tray, error) { + var result []*trays.Tray + for rows.Next() { + var t trays.Tray + var statusInt int + var statusChanged string + var providerDataJSON string + + err := rows.Scan(&t.Id, &t.TrayTypeName, &t.ProviderName, &t.GitHubOrgName, + &t.GitHubRunnerId, &t.JobRunId, &t.WorkflowRunId, &t.Repository, + &statusInt, &statusChanged, &providerDataJSON) + if err != nil { + return nil, err + } + t.Status = trays.TrayStatus(statusInt) + t.StatusChanged, _ = time.Parse(time.RFC3339Nano, statusChanged) + _ = json.Unmarshal([]byte(providerDataJSON), &t.ProviderData) + if t.ProviderData == nil { + t.ProviderData = make(map[string]string) + } + result = append(result, &t) + } + return result, rows.Err() +} diff --git a/src/server/server.go b/src/server/server.go index 4e42bf2..1bf4d35 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -11,7 +11,9 @@ import ( "cattery/lib/trays/repositories" "cattery/server/handlers" "context" + "database/sql" "errors" + "io" "net/http" "os" "os/signal" @@ -22,6 +24,8 @@ import ( log "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" + + _ "modernc.org/sqlite" ) func Start() { @@ -33,39 +37,72 @@ func Start() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - // Db connection - serverAPI := options.ServerAPI(options.ServerAPIVersion1) - opts := options.Client(). - ApplyURI(config.Get().Database.Uri). - SetServerAPIOptions(serverAPI) - - client, err := mongo.Connect(opts) - if err != nil { - logger.Fatal(err) - } + // Initialize database + var trayRepo repositories.TrayRepository + var restarterRepo_ restarterRepo.RestarterRepository + var dbCloser io.Closer - { - timeoutCtx, cf := context.WithTimeout(context.Background(), 3*time.Second) - defer cf() + switch config.Get().Database.Type { + case "sqlite": + db, err := sql.Open("sqlite", config.Get().Database.Path) + if err != nil { + logger.Fatalf("Failed to open SQLite database: %v", err) + } + // SQLite doesn't handle concurrent writes well without WAL mode + db.SetMaxOpenConns(1) + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + logger.Fatalf("Failed to enable WAL mode: %v", err) + } - err = client.Ping(timeoutCtx, nil) + tr, err := repositories.NewSqliteTrayRepository(db) if err != nil { - logger.Errorf("Failed to connect to MongoDB: %v", err) - os.Exit(1) + logger.Fatalf("Failed to initialize SQLite tray repository: %v", err) + } + rr, err := restarterRepo.NewSqliteRestarterRepository(db) + if err != nil { + logger.Fatalf("Failed to initialize SQLite restarter repository: %v", err) + } + trayRepo = tr + restarterRepo_ = rr + dbCloser = db + logger.Infof("Using SQLite database: %s", config.Get().Database.Path) + + default: // mongodb + serverAPI := options.ServerAPI(options.ServerAPIVersion1) + opts := options.Client(). + ApplyURI(config.Get().Database.Uri). + SetServerAPIOptions(serverAPI) + + client, err := mongo.Connect(opts) + if err != nil { + logger.Fatal(err) } - } - var database = client.Database(config.Get().Database.Database) + { + timeoutCtx, cf := context.WithTimeout(context.Background(), 3*time.Second) + defer cf() + err = client.Ping(timeoutCtx, nil) + if err != nil { + logger.Fatalf("Failed to connect to MongoDB: %v", err) + } + } + + database := client.Database(config.Get().Database.Database) - // Initialize tray manager and repository - var trayRepository = repositories.NewMongodbTrayRepository() - trayRepository.Connect(database.Collection("trays")) - tm := trayManager.NewTrayManager(trayRepository, providers.DefaultFactory{}) + mongoTrayRepo := repositories.NewMongodbTrayRepository() + mongoTrayRepo.Connect(database.Collection("trays")) + trayRepo = mongoTrayRepo + + mongoRestarterRepo := restarterRepo.NewMongodbRestarterRepository() + mongoRestarterRepo.Connect(database.Collection("restarters")) + restarterRepo_ = mongoRestarterRepo + + dbCloser = mongoCloser{client} + logger.Info("Using MongoDB database") + } - // Initialize restarter - var restartManagerRepository = restarterRepo.NewMongodbRestarterRepository() - restartManagerRepository.Connect(database.Collection("restarters")) - rm := restarter.NewWorkflowRestarter(restartManagerRepository) + tm := trayManager.NewTrayManager(trayRepo, providers.DefaultFactory{}) + rm := restarter.NewWorkflowRestarter(restarterRepo_) // Initialize scale set pollers — one per TrayType ssm := scaleSetPoller.NewManager() @@ -139,12 +176,21 @@ func Start() { ssm.Wait() logger.Info("All pollers stopped") - disconnectCtx, disconnectCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer disconnectCancel() - if err := client.Disconnect(disconnectCtx); err != nil { - logger.Errorf("Failed to disconnect from MongoDB: %v", err) + if err := dbCloser.Close(); err != nil { + logger.Errorf("Failed to close database: %v", err) } - logger.Info("MongoDB connection closed") + logger.Info("Database connection closed") +} + +// mongoCloser adapts mongo.Client to io.Closer. +type mongoCloser struct { + client *mongo.Client +} + +func (m mongoCloser) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return m.client.Disconnect(ctx) } func agentMux(h *handlers.Handlers) *http.ServeMux {