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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
|
|||||||
|
|
||||||
## Phases
|
## 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 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 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md)
|
||||||
- [ ] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-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 | 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 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 3: Docker Client | 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) | ⬜ |
|
| Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 1: Project Scaffold & SQLite Store
|
# Phase 1: Project Scaffold & SQLite Store
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
|
|
||||||
@@ -9,17 +9,17 @@ Initialize the Go project, establish the directory structure, and implement the
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md
|
- [x] 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)
|
- [x] 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
|
- [x] 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)
|
- [x] Task 4: Implement store initialization with auto-migration (create tables if not exist)
|
||||||
- [ ] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete)
|
- [x] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete)
|
||||||
- [ ] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete)
|
- [x] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete)
|
||||||
- [ ] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete)
|
- [x] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete)
|
||||||
- [ ] Task 8: Implement settings Get/Update (single-row config pattern)
|
- [x] Task 8: Implement settings Get/Update (single-row config pattern)
|
||||||
- [ ] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus)
|
- [x] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus)
|
||||||
- [ ] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append
|
- [x] 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 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close)
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `go.mod` — module definition and dependencies
|
- `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
|
- [ ] CRUD functions handle not-found cases properly
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this 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
|
||||||
|
|||||||
Reference in New Issue
Block a user