diff --git a/go.mod b/go.mod index 55ca66f..d340741 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,16 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.53.0 // indirect ) diff --git a/go.sum b/go.sum index 8d12937..621a529 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,35 @@ 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/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/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gophercloud/gophercloud/v2 v2.12.0 h1:Gxmc/Bog1UDKkxTcQW7MSPTDviJXpLeEgVeN5KrxoCo= github.com/gophercloud/gophercloud/v2 v2.12.0/go.mod h1:H7TTOxbLy8RIaHSNhI2GCrWIzw4Xpw8Xn2mBhCUT5kA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/app/compute/sqlite_server_repository.go b/internal/app/compute/sqlite_server_repository.go new file mode 100644 index 0000000..70cc823 --- /dev/null +++ b/internal/app/compute/sqlite_server_repository.go @@ -0,0 +1,260 @@ +package compute + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + + _ "modernc.org/sqlite" +) + +type SQLiteServerRepository struct { + db *sql.DB +} + +func OpenSQLiteServerRepository( + dataSourceName string, +) (*SQLiteServerRepository, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repository := &SQLiteServerRepository{db: db} + if err := repository.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repository, nil +} + +func (r *SQLiteServerRepository) Init() error { + _, err := r.db.Exec(` +CREATE TABLE IF NOT EXISTS compute_servers ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + image_id TEXT NOT NULL, + flavor_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + user_id TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata_json TEXT NOT NULL +)`) + + return err +} + +func (r *SQLiteServerRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteServerRepository) Create(server Server) Server { + metadataJSON := marshalStringMap(server.Metadata) + + _, err := r.db.Exec( + `INSERT INTO compute_servers ( + id, + name, + image_id, + flavor_id, + tenant_id, + user_id, + status, + progress, + created_at, + updated_at, + metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + server.ID, + server.Name, + server.ImageID, + server.FlavorID, + server.TenantID, + server.UserID, + server.Status, + server.Progress, + server.CreatedAt, + server.UpdatedAt, + metadataJSON, + ) + if err != nil { + panic(fmt.Errorf("insert server: %w", err)) + } + + return server +} + +func (r *SQLiteServerRepository) List() []Server { + rows, err := r.db.Query(` +SELECT + id, + name, + image_id, + flavor_id, + tenant_id, + user_id, + status, + progress, + created_at, + updated_at, + metadata_json +FROM compute_servers +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list servers: %w", err)) + } + defer rows.Close() + + servers := []Server{} + for rows.Next() { + server, err := scanServer(rows) + if err != nil { + panic(fmt.Errorf("scan server: %w", err)) + } + servers = append(servers, server) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate servers: %w", err)) + } + + return servers +} + +func (r *SQLiteServerRepository) Get(id string) (Server, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + image_id, + flavor_id, + tenant_id, + user_id, + status, + progress, + created_at, + updated_at, + metadata_json +FROM compute_servers +WHERE id = ?`, id) + + server, err := scanServer(row) + if errors.Is(err, sql.ErrNoRows) { + return Server{}, ErrServerNotFound + } + if err != nil { + return Server{}, err + } + + return server, nil +} + +func (r *SQLiteServerRepository) Update(server Server) (Server, error) { + metadataJSON := marshalStringMap(server.Metadata) + result, err := r.db.Exec( + `UPDATE compute_servers + SET name = ?, + image_id = ?, + flavor_id = ?, + tenant_id = ?, + user_id = ?, + status = ?, + progress = ?, + created_at = ?, + updated_at = ?, + metadata_json = ? + WHERE id = ?`, + server.Name, + server.ImageID, + server.FlavorID, + server.TenantID, + server.UserID, + server.Status, + server.Progress, + server.CreatedAt, + server.UpdatedAt, + metadataJSON, + server.ID, + ) + if err != nil { + return Server{}, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return Server{}, err + } + if rowsAffected == 0 { + return Server{}, ErrServerNotFound + } + + return server, nil +} + +func (r *SQLiteServerRepository) Delete(id string) error { + result, err := r.db.Exec(`DELETE FROM compute_servers WHERE id = ?`, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrServerNotFound + } + + return nil +} + +func (r *SQLiteServerRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM compute_servers`); err != nil { + panic(fmt.Errorf("reset servers: %w", err)) + } +} + +type serverScanner interface { + Scan(dest ...any) error +} + +func scanServer(scanner serverScanner) (Server, error) { + var server Server + var metadataJSON string + + if err := scanner.Scan( + &server.ID, + &server.Name, + &server.ImageID, + &server.FlavorID, + &server.TenantID, + &server.UserID, + &server.Status, + &server.Progress, + &server.CreatedAt, + &server.UpdatedAt, + &metadataJSON, + ); err != nil { + return Server{}, err + } + + if err := json.Unmarshal([]byte(metadataJSON), &server.Metadata); err != nil { + return Server{}, err + } + + return server, nil +} + +func marshalStringMap(value map[string]string) string { + data, err := json.Marshal(value) + if err != nil { + panic(fmt.Errorf("marshal string map: %w", err)) + } + + return string(data) +} diff --git a/internal/app/compute/sqlite_server_repository_test.go b/internal/app/compute/sqlite_server_repository_test.go new file mode 100644 index 0000000..57c5c9b --- /dev/null +++ b/internal/app/compute/sqlite_server_repository_test.go @@ -0,0 +1,100 @@ +package compute_test + +import ( + "path/filepath" + "testing" + + "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/stretchr/testify/suite" +) + +type SQLiteServerRepositorySuite struct { + suite.Suite + repository *compute.SQLiteServerRepository +} + +func TestSQLiteServerRepositorySuite(t *testing.T) { + suite.Run(t, new(SQLiteServerRepositorySuite)) +} + +func (s *SQLiteServerRepositorySuite) SetupTest() { + repository, err := compute.OpenSQLiteServerRepository(":memory:") + s.Require().NoError(err) + + s.repository = repository +} + +func (s *SQLiteServerRepositorySuite) TearDownTest() { + s.Require().NoError(s.repository.Close()) +} + +func (s *SQLiteServerRepositorySuite) TestCreateListGetAndUpdateServer() { + created := s.repository.Create(serverFixture("srv-1")) + created.Status = "ACTIVE" + created.Progress = 100 + + updated, err := s.repository.Update(created) + s.Require().NoError(err) + listed := s.repository.List() + found, err := s.repository.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(updated, listed[0]) + s.Assert().Equal(updated, found) +} + +func (s *SQLiteServerRepositorySuite) TestDeleteServer() { + created := s.repository.Create(serverFixture("srv-1")) + + err := s.repository.Delete(created.ID) + s.Require().NoError(err) + + _, err = s.repository.Get(created.ID) + s.Require().ErrorIs(err, compute.ErrServerNotFound) + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteServerRepositorySuite) TestResetClearsServers() { + s.repository.Create(serverFixture("srv-1")) + + s.repository.Reset() + + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteServerRepositorySuite) TestFileBackedDatabasePersistsServers() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repository, err := compute.OpenSQLiteServerRepository(path) + s.Require().NoError(err) + + created := repository.Create(serverFixture("srv-1")) + s.Require().NoError(repository.Close()) + + reopened, err := compute.OpenSQLiteServerRepository(path) + s.Require().NoError(err) + defer reopened.Close() + + found, err := reopened.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal(created, found) +} + +func serverFixture(id string) compute.Server { + return compute.Server{ + ID: id, + Name: "web", + ImageID: "img-1", + FlavorID: "1", + TenantID: "demo", + UserID: "admin", + Status: "BUILD", + Progress: 0, + CreatedAt: "2026-07-03T00:00:00Z", + UpdatedAt: "2026-07-03T00:00:00Z", + Metadata: map[string]string{ + "role": "web", + }, + } +} diff --git a/internal/app/image/sqlite_repository.go b/internal/app/image/sqlite_repository.go new file mode 100644 index 0000000..5d60328 --- /dev/null +++ b/internal/app/image/sqlite_repository.go @@ -0,0 +1,226 @@ +package image + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + + _ "modernc.org/sqlite" +) + +type SQLiteRepository struct { + db *sql.DB +} + +func OpenSQLiteRepository(dataSourceName string) (*SQLiteRepository, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repository := &SQLiteRepository{db: db} + if err := repository.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repository, nil +} + +func (r *SQLiteRepository) Init() error { + _, err := r.db.Exec(` +CREATE TABLE IF NOT EXISTS images ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL, + container_format TEXT NOT NULL, + disk_format TEXT NOT NULL, + min_disk INTEGER NOT NULL, + min_ram INTEGER NOT NULL, + protected INTEGER NOT NULL, + visibility TEXT NOT NULL, + tags_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +)`) + + return err +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteRepository) Create(image Image) Image { + tagsJSON, err := json.Marshal(image.Tags) + if err != nil { + panic(fmt.Errorf("marshal image tags: %w", err)) + } + + _, err = r.db.Exec( + `INSERT INTO images ( + id, + name, + status, + container_format, + disk_format, + min_disk, + min_ram, + protected, + visibility, + tags_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + image.ID, + image.Name, + image.Status, + image.ContainerFormat, + image.DiskFormat, + image.MinDisk, + image.MinRAM, + boolToInt(image.Protected), + image.Visibility, + string(tagsJSON), + image.CreatedAt, + image.UpdatedAt, + ) + if err != nil { + panic(fmt.Errorf("insert image: %w", err)) + } + + return image +} + +func (r *SQLiteRepository) List() []Image { + rows, err := r.db.Query(` +SELECT + id, + name, + status, + container_format, + disk_format, + min_disk, + min_ram, + protected, + visibility, + tags_json, + created_at, + updated_at +FROM images +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list images: %w", err)) + } + defer rows.Close() + + images := []Image{} + for rows.Next() { + image, err := scanImage(rows) + if err != nil { + panic(fmt.Errorf("scan image: %w", err)) + } + images = append(images, image) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate images: %w", err)) + } + + return images +} + +func (r *SQLiteRepository) Get(id string) (Image, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + status, + container_format, + disk_format, + min_disk, + min_ram, + protected, + visibility, + tags_json, + created_at, + updated_at +FROM images +WHERE id = ?`, id) + + image, err := scanImage(row) + if errors.Is(err, sql.ErrNoRows) { + return Image{}, ErrImageNotFound + } + if err != nil { + return Image{}, err + } + + return image, nil +} + +func (r *SQLiteRepository) Delete(id string) error { + result, err := r.db.Exec(`DELETE FROM images WHERE id = ?`, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrImageNotFound + } + + return nil +} + +func (r *SQLiteRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM images`); err != nil { + panic(fmt.Errorf("reset images: %w", err)) + } +} + +type imageScanner interface { + Scan(dest ...any) error +} + +func scanImage(scanner imageScanner) (Image, error) { + var image Image + var protected int + var tagsJSON string + + if err := scanner.Scan( + &image.ID, + &image.Name, + &image.Status, + &image.ContainerFormat, + &image.DiskFormat, + &image.MinDisk, + &image.MinRAM, + &protected, + &image.Visibility, + &tagsJSON, + &image.CreatedAt, + &image.UpdatedAt, + ); err != nil { + return Image{}, err + } + + if err := json.Unmarshal([]byte(tagsJSON), &image.Tags); err != nil { + return Image{}, err + } + image.Protected = protected != 0 + + return image, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} diff --git a/internal/app/image/sqlite_repository_test.go b/internal/app/image/sqlite_repository_test.go new file mode 100644 index 0000000..0542a3b --- /dev/null +++ b/internal/app/image/sqlite_repository_test.go @@ -0,0 +1,121 @@ +package image_test + +import ( + "path/filepath" + "testing" + + "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/stretchr/testify/suite" +) + +type SQLiteRepositorySuite struct { + suite.Suite + repository *image.SQLiteRepository +} + +func TestSQLiteRepositorySuite(t *testing.T) { + suite.Run(t, new(SQLiteRepositorySuite)) +} + +func (s *SQLiteRepositorySuite) SetupTest() { + repository, err := image.OpenSQLiteRepository(":memory:") + s.Require().NoError(err) + + s.repository = repository +} + +func (s *SQLiteRepositorySuite) TearDownTest() { + s.Require().NoError(s.repository.Close()) +} + +func (s *SQLiteRepositorySuite) TestCreateListAndGetImage() { + created := s.repository.Create(image.Image{ + ID: "img-1", + Name: "ubuntu", + Status: "queued", + ContainerFormat: "bare", + DiskFormat: "qcow2", + MinDisk: 1, + MinRAM: 2, + Protected: true, + Visibility: "private", + Tags: []string{"linux", "test"}, + CreatedAt: "2026-07-03T00:00:00Z", + UpdatedAt: "2026-07-03T00:00:00Z", + }) + + listed := s.repository.List() + found, err := s.repository.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal(created, found) +} + +func (s *SQLiteRepositorySuite) TestDeleteImage() { + created := s.repository.Create(image.Image{ + ID: "img-1", + Name: "ubuntu", + Status: "queued", + ContainerFormat: "bare", + DiskFormat: "qcow2", + Visibility: "private", + Tags: []string{}, + CreatedAt: "2026-07-03T00:00:00Z", + UpdatedAt: "2026-07-03T00:00:00Z", + }) + + err := s.repository.Delete(created.ID) + s.Require().NoError(err) + + _, err = s.repository.Get(created.ID) + s.Require().ErrorIs(err, image.ErrImageNotFound) + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteRepositorySuite) TestResetClearsImages() { + s.repository.Create(image.Image{ + ID: "img-1", + Name: "ubuntu", + Status: "queued", + ContainerFormat: "bare", + DiskFormat: "qcow2", + Visibility: "private", + Tags: []string{}, + CreatedAt: "2026-07-03T00:00:00Z", + UpdatedAt: "2026-07-03T00:00:00Z", + }) + + s.repository.Reset() + + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteRepositorySuite) TestFileBackedDatabasePersistsImages() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repository, err := image.OpenSQLiteRepository(path) + s.Require().NoError(err) + + created := repository.Create(image.Image{ + ID: "img-1", + Name: "ubuntu", + Status: "queued", + ContainerFormat: "bare", + DiskFormat: "qcow2", + Visibility: "private", + Tags: []string{}, + CreatedAt: "2026-07-03T00:00:00Z", + UpdatedAt: "2026-07-03T00:00:00Z", + }) + s.Require().NoError(repository.Close()) + + reopened, err := image.OpenSQLiteRepository(path) + s.Require().NoError(err) + defer reopened.Close() + + found, err := reopened.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal(created, found) +} diff --git a/internal/app/volume/sqlite_repository.go b/internal/app/volume/sqlite_repository.go new file mode 100644 index 0000000..8c33726 --- /dev/null +++ b/internal/app/volume/sqlite_repository.go @@ -0,0 +1,277 @@ +package volume + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + + _ "modernc.org/sqlite" +) + +type SQLiteRepository struct { + db *sql.DB +} + +func OpenSQLiteRepository(dataSourceName string) (*SQLiteRepository, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repository := &SQLiteRepository{db: db} + if err := repository.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repository, nil +} + +func (r *SQLiteRepository) Init() error { + _, err := r.db.Exec(` +CREATE TABLE IF NOT EXISTS volumes ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + size INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + volume_type TEXT NOT NULL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + bootable TEXT NOT NULL, + encrypted INTEGER NOT NULL, + multiattach INTEGER NOT NULL +)`) + + return err +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteRepository) Create(volume Volume) Volume { + metadataJSON := marshalStringMap(volume.Metadata) + _, err := r.db.Exec( + `INSERT INTO volumes ( + id, + status, + size, + name, + description, + volume_type, + metadata_json, + created_at, + updated_at, + bootable, + encrypted, + multiattach + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + volume.ID, + volume.Status, + volume.Size, + volume.Name, + volume.Description, + volume.VolumeType, + metadataJSON, + volume.CreatedAt, + volume.UpdatedAt, + volume.Bootable, + boolToInt(volume.Encrypted), + boolToInt(volume.Multiattach), + ) + if err != nil { + panic(fmt.Errorf("insert volume: %w", err)) + } + + return volume +} + +func (r *SQLiteRepository) List() []Volume { + rows, err := r.db.Query(` +SELECT + id, + status, + size, + name, + description, + volume_type, + metadata_json, + created_at, + updated_at, + bootable, + encrypted, + multiattach +FROM volumes +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list volumes: %w", err)) + } + defer rows.Close() + + volumes := []Volume{} + for rows.Next() { + volume, err := scanVolume(rows) + if err != nil { + panic(fmt.Errorf("scan volume: %w", err)) + } + volumes = append(volumes, volume) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate volumes: %w", err)) + } + + return volumes +} + +func (r *SQLiteRepository) Get(id string) (Volume, error) { + row := r.db.QueryRow(` +SELECT + id, + status, + size, + name, + description, + volume_type, + metadata_json, + created_at, + updated_at, + bootable, + encrypted, + multiattach +FROM volumes +WHERE id = ?`, id) + + volume, err := scanVolume(row) + if errors.Is(err, sql.ErrNoRows) { + return Volume{}, ErrVolumeNotFound + } + if err != nil { + return Volume{}, err + } + + return volume, nil +} + +func (r *SQLiteRepository) Update(volume Volume) (Volume, error) { + metadataJSON := marshalStringMap(volume.Metadata) + result, err := r.db.Exec( + `UPDATE volumes + SET status = ?, + size = ?, + name = ?, + description = ?, + volume_type = ?, + metadata_json = ?, + created_at = ?, + updated_at = ?, + bootable = ?, + encrypted = ?, + multiattach = ? + WHERE id = ?`, + volume.Status, + volume.Size, + volume.Name, + volume.Description, + volume.VolumeType, + metadataJSON, + volume.CreatedAt, + volume.UpdatedAt, + volume.Bootable, + boolToInt(volume.Encrypted), + boolToInt(volume.Multiattach), + volume.ID, + ) + if err != nil { + return Volume{}, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return Volume{}, err + } + if rowsAffected == 0 { + return Volume{}, ErrVolumeNotFound + } + + return volume, nil +} + +func (r *SQLiteRepository) Delete(id string) error { + result, err := r.db.Exec(`DELETE FROM volumes WHERE id = ?`, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrVolumeNotFound + } + + return nil +} + +func (r *SQLiteRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM volumes`); err != nil { + panic(fmt.Errorf("reset volumes: %w", err)) + } +} + +type volumeScanner interface { + Scan(dest ...any) error +} + +func scanVolume(scanner volumeScanner) (Volume, error) { + var volume Volume + var metadataJSON string + var encrypted int + var multiattach int + + if err := scanner.Scan( + &volume.ID, + &volume.Status, + &volume.Size, + &volume.Name, + &volume.Description, + &volume.VolumeType, + &metadataJSON, + &volume.CreatedAt, + &volume.UpdatedAt, + &volume.Bootable, + &encrypted, + &multiattach, + ); err != nil { + return Volume{}, err + } + + if err := json.Unmarshal([]byte(metadataJSON), &volume.Metadata); err != nil { + return Volume{}, err + } + volume.Encrypted = encrypted != 0 + volume.Multiattach = multiattach != 0 + + return volume, nil +} + +func marshalStringMap(value map[string]string) string { + data, err := json.Marshal(value) + if err != nil { + panic(fmt.Errorf("marshal string map: %w", err)) + } + + return string(data) +} + +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} diff --git a/internal/app/volume/sqlite_repository_test.go b/internal/app/volume/sqlite_repository_test.go new file mode 100644 index 0000000..b736443 --- /dev/null +++ b/internal/app/volume/sqlite_repository_test.go @@ -0,0 +1,100 @@ +package volume_test + +import ( + "path/filepath" + "testing" + + "github.com/JSYoo5B/SandStack/internal/app/volume" + "github.com/stretchr/testify/suite" +) + +type SQLiteRepositorySuite struct { + suite.Suite + repository *volume.SQLiteRepository +} + +func TestSQLiteRepositorySuite(t *testing.T) { + suite.Run(t, new(SQLiteRepositorySuite)) +} + +func (s *SQLiteRepositorySuite) SetupTest() { + repository, err := volume.OpenSQLiteRepository(":memory:") + s.Require().NoError(err) + + s.repository = repository +} + +func (s *SQLiteRepositorySuite) TearDownTest() { + s.Require().NoError(s.repository.Close()) +} + +func (s *SQLiteRepositorySuite) TestCreateListGetAndUpdateVolume() { + created := s.repository.Create(volumeFixture("vol-1")) + created.Status = "available" + + updated, err := s.repository.Update(created) + s.Require().NoError(err) + listed := s.repository.List() + found, err := s.repository.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(updated, listed[0]) + s.Assert().Equal(updated, found) +} + +func (s *SQLiteRepositorySuite) TestDeleteVolume() { + created := s.repository.Create(volumeFixture("vol-1")) + + err := s.repository.Delete(created.ID) + s.Require().NoError(err) + + _, err = s.repository.Get(created.ID) + s.Require().ErrorIs(err, volume.ErrVolumeNotFound) + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteRepositorySuite) TestResetClearsVolumes() { + s.repository.Create(volumeFixture("vol-1")) + + s.repository.Reset() + + s.Assert().Empty(s.repository.List()) +} + +func (s *SQLiteRepositorySuite) TestFileBackedDatabasePersistsVolumes() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repository, err := volume.OpenSQLiteRepository(path) + s.Require().NoError(err) + + created := repository.Create(volumeFixture("vol-1")) + s.Require().NoError(repository.Close()) + + reopened, err := volume.OpenSQLiteRepository(path) + s.Require().NoError(err) + defer reopened.Close() + + found, err := reopened.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal(created, found) +} + +func volumeFixture(id string) volume.Volume { + return volume.Volume{ + ID: id, + Status: "creating", + Size: 1, + Name: "database", + Description: "test volume", + VolumeType: "__DEFAULT__", + Metadata: map[string]string{ + "role": "database", + }, + CreatedAt: "2026-07-03T00:00:00.000000", + UpdatedAt: "2026-07-03T00:00:00.000000", + Bootable: "false", + Encrypted: true, + Multiattach: true, + } +}