feat(docker-watcher): phase 1 - project scaffold & SQLite store

Initialize Go module, directory structure, and full SQLite store layer:
- 7-table schema (projects, stages, registries, settings, instances,
  deploys, deploy_logs) with auto-migration
- CRUD operations for all entities with proper error handling
- ErrNotFound sentinel for distinguishing 404 from 500 in handlers
- WAL mode, foreign keys, busy timeout pragmas
This commit is contained in:
2026-03-27 20:52:29 +03:00
parent 0bb52f9ec6
commit d63c831d15
12 changed files with 982 additions and 15 deletions
+167
View File
@@ -0,0 +1,167 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateDeploy inserts a new deploy record.
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
d.ID = uuid.New().String()
d.StartedAt = now()
if d.Status == "" {
d.Status = "pending"
}
_, err := s.db.Exec(
`INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error,
)
if err != nil {
return Deploy{}, fmt.Errorf("insert deploy: %w", err)
}
return d, nil
}
// GetDeployByID returns a single deploy by its ID.
func (s *Store) GetDeployByID(id string) (Deploy, error) {
var d Deploy
err := s.db.QueryRow(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE id = ?`, id,
).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error)
if errors.Is(err, sql.ErrNoRows) {
return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
if err != nil {
return Deploy{}, fmt.Errorf("query deploy: %w", err)
}
return d, nil
}
// GetDeploysByProjectID returns all deploys for a project, newest first.
func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// GetRecentDeploys returns the most recent deploys across all projects.
func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) {
rows, err := s.db.Query(
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
FROM deploys ORDER BY started_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, fmt.Errorf("query recent deploys: %w", err)
}
defer rows.Close()
return scanDeploys(rows)
}
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
ts := now()
var finishedAt string
if isTerminalDeployStatus(status) {
finishedAt = ts
}
result, err := s.db.Exec(
`UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`,
status, deployErr, finishedAt, id,
)
if err != nil {
return fmt.Errorf("update deploy status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", id, ErrNotFound)
}
return nil
}
// SetDeployInstanceID links a deploy to the instance it created.
func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error {
result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID)
if err != nil {
return fmt.Errorf("set deploy instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound)
}
return nil
}
// AppendDeployLog adds a log entry for a deploy.
func (s *Store) AppendDeployLog(deployID string, message string, level string) error {
if level == "" {
level = "info"
}
_, err := s.db.Exec(
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
deployID, message, level, now(),
)
if err != nil {
return fmt.Errorf("append deploy log: %w", err)
}
return nil
}
// GetDeployLogs returns all log entries for a deploy, ordered chronologically.
func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
rows, err := s.db.Query(
`SELECT id, deploy_id, message, level, created_at
FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID,
)
if err != nil {
return nil, fmt.Errorf("query deploy logs: %w", err)
}
defer rows.Close()
var logs []DeployLog
for rows.Next() {
var l DeployLog
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
return nil, fmt.Errorf("scan deploy log: %w", err)
}
logs = append(logs, l)
}
return logs, rows.Err()
}
// scanDeploys is a helper that scans deploy rows from a cursor.
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
var deploys []Deploy
for rows.Next() {
var d Deploy
if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil {
return nil, fmt.Errorf("scan deploy: %w", err)
}
deploys = append(deploys, d)
}
return deploys, rows.Err()
}
// isTerminalDeployStatus returns true if the status indicates the deploy is finished.
func isTerminalDeployStatus(status string) bool {
switch status {
case "success", "failed", "rolled_back":
return true
default:
return false
}
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateInstance inserts a new instance record.
func (s *Store) CreateInstance(inst Instance) (Instance, error) {
inst.ID = uuid.New().String()
inst.CreatedAt = now()
inst.UpdatedAt = inst.CreatedAt
_, err := s.db.Exec(
`INSERT INTO instances (id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag,
inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.CreatedAt, inst.UpdatedAt,
)
if err != nil {
return Instance{}, fmt.Errorf("insert instance: %w", err)
}
return inst, nil
}
// GetInstanceByID returns a single instance by its ID.
func (s *Store) GetInstanceByID(id string) (Instance, error) {
var inst Instance
err := s.db.QueryRow(
`SELECT id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at
FROM instances WHERE id = ?`, id,
).Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag,
&inst.Subdomain, &inst.NpmProxyID, &inst.Status, &inst.Port, &inst.CreatedAt, &inst.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Instance{}, fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
if err != nil {
return Instance{}, fmt.Errorf("query instance: %w", err)
}
return inst, nil
}
// GetInstancesByStageID returns all instances for a given stage.
func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) {
rows, err := s.db.Query(
`SELECT id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at
FROM instances WHERE stage_id = ? ORDER BY created_at DESC`, stageID,
)
if err != nil {
return nil, fmt.Errorf("query instances: %w", err)
}
defer rows.Close()
var instances []Instance
for rows.Next() {
var inst Instance
if err := rows.Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag,
&inst.Subdomain, &inst.NpmProxyID, &inst.Status, &inst.Port, &inst.CreatedAt, &inst.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan instance: %w", err)
}
instances = append(instances, inst)
}
return instances, rows.Err()
}
// UpdateInstance updates an existing instance's mutable fields.
func (s *Store) UpdateInstance(inst Instance) error {
inst.UpdatedAt = now()
result, err := s.db.Exec(
`UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, updated_at=?
WHERE id=?`,
inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag,
inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.UpdatedAt, inst.ID,
)
if err != nil {
return fmt.Errorf("update instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", inst.ID, ErrNotFound)
}
return nil
}
// UpdateInstanceStatus sets only the status field on an instance.
func (s *Store) UpdateInstanceStatus(id string, status string) error {
ts := now()
result, err := s.db.Exec(
`UPDATE instances SET status=?, updated_at=? WHERE id=?`,
status, ts, id,
)
if err != nil {
return fmt.Errorf("update instance status: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteInstance removes an instance by ID.
func (s *Store) DeleteInstance(id string) error {
result, err := s.db.Exec(`DELETE FROM instances WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete instance: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("instance %s: %w", id, ErrNotFound)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package store
// Project represents a deployable application.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stage represents a deployment stage within a project (e.g. dev, rel, prod).
type Stage struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy bool `json:"auto_deploy"`
MaxInstances int `json:"max_instances"`
Confirm bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Registry represents a container image registry.
type Registry struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
Token string `json:"token"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Settings holds global application configuration (single-row pattern).
type Settings struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"`
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
WebhookSecret string `json:"webhook_secret"`
PollingInterval string `json:"polling_interval"`
UpdatedAt string `json:"updated_at"`
}
// Instance represents a running (or stopped) container for a project stage.
type Instance struct {
ID string `json:"id"`
StageID string `json:"stage_id"`
ProjectID string `json:"project_id"`
ContainerID string `json:"container_id"`
ImageTag string `json:"image_tag"`
Subdomain string `json:"subdomain"`
NpmProxyID int `json:"npm_proxy_id"`
Status string `json:"status"` // running, stopped, failed, removing
Port int `json:"port"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Deploy represents a deployment attempt.
type Deploy struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
StageID string `json:"stage_id"`
InstanceID string `json:"instance_id"`
ImageTag string `json:"image_tag"`
Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Error string `json:"error"`
}
// DeployLog is a single log entry for a deploy.
type DeployLog struct {
ID int64 `json:"id"`
DeployID string `json:"deploy_id"`
Message string `json:"message"`
Level string `json:"level"` // info, warn, error
CreatedAt string `json:"created_at"`
}
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateProject inserts a new project and returns it.
func (s *Store) CreateProject(p Project) (Project, error) {
p.ID = uuid.New().String()
p.CreatedAt = now()
p.UpdatedAt = p.CreatedAt
_, err := s.db.Exec(
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return Project{}, fmt.Errorf("insert project: %w", err)
}
return p, nil
}
// GetProjectByID returns a single project by its ID.
func (s *Store) GetProjectByID(id string) (Project, error) {
var p Project
err := s.db.QueryRow(
`SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
}
if err != nil {
return Project{}, fmt.Errorf("query project: %w", err)
}
return p, nil
}
// GetAllProjects returns every project ordered by name.
func (s *Store) GetAllProjects() ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at
FROM projects ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
defer rows.Close()
var projects []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// UpdateProject updates an existing project's mutable fields.
func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = now()
result, err := s.db.Exec(
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=?
WHERE id=?`,
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.UpdatedAt, p.ID,
)
if err != nil {
return fmt.Errorf("update project: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: %w", p.ID, ErrNotFound)
}
return nil
}
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
func (s *Store) DeleteProject(id string) error {
result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateRegistry inserts a new registry.
func (s *Store) CreateRegistry(r Registry) (Registry, error) {
r.ID = uuid.New().String()
r.CreatedAt = now()
r.UpdatedAt = r.CreatedAt
_, err := s.db.Exec(
`INSERT INTO registries (id, name, url, type, token, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.Name, r.URL, r.Type, r.Token, r.CreatedAt, r.UpdatedAt,
)
if err != nil {
return Registry{}, fmt.Errorf("insert registry: %w", err)
}
return r, nil
}
// GetRegistryByID returns a single registry by its ID.
func (s *Store) GetRegistryByID(id string) (Registry, error) {
var r Registry
err := s.db.QueryRow(
`SELECT id, name, url, type, token, created_at, updated_at
FROM registries WHERE id = ?`, id,
).Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Registry{}, fmt.Errorf("registry %s: %w", id, ErrNotFound)
}
if err != nil {
return Registry{}, fmt.Errorf("query registry: %w", err)
}
return r, nil
}
// GetAllRegistries returns every registry ordered by name.
func (s *Store) GetAllRegistries() ([]Registry, error) {
rows, err := s.db.Query(
`SELECT id, name, url, type, token, created_at, updated_at
FROM registries ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query registries: %w", err)
}
defer rows.Close()
var registries []Registry
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan registry: %w", err)
}
registries = append(registries, r)
}
return registries, rows.Err()
}
// UpdateRegistry updates an existing registry's mutable fields.
func (s *Store) UpdateRegistry(r Registry) error {
r.UpdatedAt = now()
result, err := s.db.Exec(
`UPDATE registries SET name=?, url=?, type=?, token=?, updated_at=?
WHERE id=?`,
r.Name, r.URL, r.Type, r.Token, r.UpdatedAt, r.ID,
)
if err != nil {
return fmt.Errorf("update registry: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("registry %s: %w", r.ID, ErrNotFound)
}
return nil
}
// DeleteRegistry removes a registry by ID.
func (s *Store) DeleteRegistry(id string) error {
result, err := s.db.Exec(`DELETE FROM registries WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete registry: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("registry %s: %w", id, ErrNotFound)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"fmt"
)
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval, updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err)
}
return st, nil
}
// UpdateSettings upserts the global settings row.
func (s *Store) UpdateSettings(st Settings) error {
st.UpdatedAt = now()
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.UpdatedAt,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String()
st.CreatedAt = now()
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err)
}
return st, nil
}
// GetStagesByProjectID returns all stages for a given project.
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
rows, err := s.db.Query(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at
FROM stages WHERE project_id = ? ORDER BY name`, projectID,
)
if err != nil {
return nil, fmt.Errorf("query stages: %w", err)
}
defer rows.Close()
var stages []Stage
for rows.Next() {
st, err := scanStage(rows)
if err != nil {
return nil, err
}
stages = append(stages, st)
}
return stages, rows.Err()
}
// GetStageByID returns a single stage by its ID.
func (s *Store) GetStageByID(id string) (Stage, error) {
var st Stage
var autoDeploy, confirm int
err := s.db.QueryRow(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at
FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
if err != nil {
return Stage{}, fmt.Errorf("query stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
return st, nil
}
// UpdateStage updates an existing stage's mutable fields.
func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = now()
result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=?
WHERE id=?`,
st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
)
if err != nil {
return fmt.Errorf("update stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound)
}
return nil
}
// DeleteStage removes a stage by ID. Cascading deletes handle child instances.
func (s *Store) DeleteStage(id string) error {
result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stage: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// boolToInt converts a bool to an integer for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// scanStage scans a stage row from a *sql.Rows cursor.
func scanStage(rows *sql.Rows) (Stage, error) {
var st Stage
var autoDeploy, confirm int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
return st, nil
}
+155
View File
@@ -0,0 +1,155 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a requested entity does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps the SQLite database connection and provides access to all query methods.
type Store struct {
db *sql.DB
}
// New opens a SQLite database at the given path and runs auto-migration.
func New(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("exec pragma %q: %w", p, err)
}
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// migrate creates all tables if they do not already exist.
func (s *Store) migrate() error {
_, err := s.db.Exec(schema)
return err
}
const schema = `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
registry TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 0,
healthcheck TEXT NOT NULL DEFAULT '',
env TEXT NOT NULL DEFAULT '{}',
volumes TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS stages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tag_pattern TEXT NOT NULL DEFAULT '*',
auto_deploy INTEGER NOT NULL DEFAULT 0,
max_instances INTEGER NOT NULL DEFAULT 1,
confirm INTEGER NOT NULL DEFAULT 0,
promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS registries (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'generic',
token TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
domain TEXT NOT NULL DEFAULT '',
server_ip TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
notification_url TEXT NOT NULL DEFAULT '',
npm_url TEXT NOT NULL DEFAULT '',
npm_email TEXT NOT NULL DEFAULT '',
npm_password TEXT NOT NULL DEFAULT '',
webhook_secret TEXT NOT NULL DEFAULT '',
polling_interval TEXT NOT NULL DEFAULT '5m',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
container_id TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL,
subdomain TEXT NOT NULL DEFAULT '',
npm_proxy_id INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'stopped',
port INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deploys (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL DEFAULT '',
image_tag TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS deploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE,
message TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Seed the settings row if it does not exist.
INSERT OR IGNORE INTO settings (id) VALUES (1);
`
// now returns the current time formatted for SQLite storage.
func now() string {
return time.Now().UTC().Format("2006-01-02 15:04:05")
}