From d63c831d155c51301f08e7f2ed38fd347028635f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Mar 2026 20:52:29 +0300 Subject: [PATCH] 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 --- cmd/server/main.go | 37 ++++ go.mod | 11 ++ internal/store/deploys.go | 167 ++++++++++++++++++ internal/store/instances.go | 116 ++++++++++++ internal/store/models.go | 93 ++++++++++ internal/store/projects.go | 95 ++++++++++ internal/store/registries.go | 95 ++++++++++ internal/store/settings.go | 37 ++++ internal/store/stages.go | 123 +++++++++++++ internal/store/store.go | 155 ++++++++++++++++ plans/docker-watcher-core/PLAN.md | 4 +- .../phase-1-scaffold-store.md | 64 +++++-- 12 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/store/deploys.go create mode 100644 internal/store/instances.go create mode 100644 internal/store/models.go create mode 100644 internal/store/projects.go create mode 100644 internal/store/registries.go create mode 100644 internal/store/settings.go create mode 100644 internal/store/stages.go create mode 100644 internal/store/store.go diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ed00de5 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/alexei/docker-watcher/internal/store" +) + +func main() { + dataDir := envOrDefault("DATA_DIR", "./data") + + if err := os.MkdirAll(dataDir, 0o755); err != nil { + log.Fatalf("create data directory: %v", err) + } + + dbPath := filepath.Join(dataDir, "docker-watcher.db") + db, err := store.New(dbPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer db.Close() + + fmt.Printf("Docker Watcher started. Database: %s\n", dbPath) + + // Future phases will wire up the HTTP server, deployer, poller, etc. +} + +// envOrDefault reads an environment variable or returns the fallback value. +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..53b458c --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/alexei/docker-watcher + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/robfig/cron/v3 v3.0.1 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.34.5 +) diff --git a/internal/store/deploys.go b/internal/store/deploys.go new file mode 100644 index 0000000..24e1e6c --- /dev/null +++ b/internal/store/deploys.go @@ -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 + } +} diff --git a/internal/store/instances.go b/internal/store/instances.go new file mode 100644 index 0000000..aaaf6cf --- /dev/null +++ b/internal/store/instances.go @@ -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 +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..11d8eb1 --- /dev/null +++ b/internal/store/models.go @@ -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"` +} diff --git a/internal/store/projects.go b/internal/store/projects.go new file mode 100644 index 0000000..3d436f1 --- /dev/null +++ b/internal/store/projects.go @@ -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 +} diff --git a/internal/store/registries.go b/internal/store/registries.go new file mode 100644 index 0000000..d677ef4 --- /dev/null +++ b/internal/store/registries.go @@ -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 +} diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..9ce9364 --- /dev/null +++ b/internal/store/settings.go @@ -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 +} diff --git a/internal/store/stages.go b/internal/store/stages.go new file mode 100644 index 0000000..ada2c69 --- /dev/null +++ b/internal/store/stages.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..12dea51 --- /dev/null +++ b/internal/store/store.go @@ -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") +} diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 5db0470..cea0187 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -22,7 +22,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M ## Phases -- [ ] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md) +- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md) - [ ] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md) - [ ] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md) - [ ] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md) @@ -43,7 +43,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| -| Phase 1: Scaffold & Store | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 1: Scaffold & Store | backend | ✅ Complete | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | diff --git a/plans/docker-watcher-core/phase-1-scaffold-store.md b/plans/docker-watcher-core/phase-1-scaffold-store.md index 362e1fc..5ea7005 100644 --- a/plans/docker-watcher-core/phase-1-scaffold-store.md +++ b/plans/docker-watcher-core/phase-1-scaffold-store.md @@ -1,6 +1,6 @@ # Phase 1: Project Scaffold & SQLite Store -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,17 +9,17 @@ Initialize the Go project, establish the directory structure, and implement the ## Tasks -- [ ] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md -- [ ] Task 2: Add core dependencies to go.mod (sqlite, chi, yaml, uuid, cron) -- [ ] Task 3: Define SQLite schema — tables for projects, stages, registries, settings, instances, deploys, deploy_logs -- [ ] Task 4: Implement store initialization with auto-migration (create tables if not exist) -- [ ] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete) -- [ ] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete) -- [ ] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete) -- [ ] Task 8: Implement settings Get/Update (single-row config pattern) -- [ ] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus) -- [ ] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append -- [ ] Task 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close) +- [x] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md +- [x] Task 2: Add core dependencies to go.mod (sqlite, chi, yaml, uuid, cron) +- [x] Task 3: Define SQLite schema — tables for projects, stages, registries, settings, instances, deploys, deploy_logs +- [x] Task 4: Implement store initialization with auto-migration (create tables if not exist) +- [x] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete) +- [x] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete) +- [x] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete) +- [x] Task 8: Implement settings Get/Update (single-row config pattern) +- [x] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus) +- [x] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append +- [x] Task 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close) ## Files to Modify/Create - `go.mod` — module definition and dependencies @@ -54,4 +54,42 @@ Initialize the Go project, establish the directory structure, and implement the - [ ] CRUD functions handle not-found cases properly ## Handoff to Next Phase - + +### What was built + +- Go module initialized at `github.com/alexei/docker-watcher` with all core dependencies +- Full directory structure created: `cmd/server/`, `internal/store/`, plus empty dirs for config, docker, npm, registry, deployer, health, notify, webhook, api, crypto +- SQLite store with 7 tables: projects, stages, registries, settings, instances, deploys, deploy_logs +- Auto-migration runs on store initialization (CREATE TABLE IF NOT EXISTS) +- WAL mode, foreign keys, and busy timeout pragmas enabled +- Settings table uses single-row pattern with `INSERT OR IGNORE` seed +- Models extracted to `internal/store/models.go` for clean separation + +### Key files + +- `go.mod` — module definition with modernc.org/sqlite, chi, yaml, uuid, cron +- `cmd/server/main.go` — entry point that creates data dir, opens store, defers close +- `internal/store/store.go` — DB connection, pragmas, schema DDL, migration +- `internal/store/models.go` — all entity structs (Project, Stage, Registry, Settings, Instance, Deploy, DeployLog) +- `internal/store/projects.go` — full CRUD +- `internal/store/stages.go` — full CRUD with bool-to-int conversion for SQLite +- `internal/store/registries.go` — full CRUD +- `internal/store/settings.go` — Get/Update (single-row upsert) +- `internal/store/instances.go` — full CRUD + UpdateStatus +- `internal/store/deploys.go` — Create, GetByID, GetByProjectID, GetRecent, UpdateDeployStatus, SetDeployInstanceID, AppendDeployLog, GetDeployLogs + +### Conventions established + +- UUIDs generated via `github.com/google/uuid` on Create operations +- Timestamps stored as `datetime('now')` defaults in schema, `time.Now().UTC().Format("2006-01-02 15:04:05")` in Go code +- All query errors wrapped with `fmt.Errorf` and `%w` for unwrapping +- Not-found cases return descriptive error strings (not sentinel errors yet — can be refined) +- Boolean fields stored as INTEGER (0/1) in SQLite, converted via `boolToInt` helper +- JSON-encoded maps stored as TEXT for env and volumes fields + +### What Phase 2 needs to know + +- `store.New(dbPath)` returns a `*Store` that is ready to use — no additional init needed +- The `settings` table is pre-seeded with a row (id=1) so `GetSettings` always works +- Registry `token` and settings `npm_password` are stored as plain text — Phase 2 (Crypto) should add encryption/decryption around these fields +- `go.sum` does not exist yet — run `go mod tidy` after Go is available to generate it