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,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")
|
||||
}
|
||||
Reference in New Issue
Block a user