db235c1412
CRUD on Project / Stack / StaticSite now keeps a paired Workload row in sync. Secret setters (webhook secret, signing secret, require-signature toggle, notification secret) all re-sync after mutating the source-of-truth row so the workload row always reflects the canonical state. Delete cascades: DeleteProject/Stack/StaticSite now drop the matching workload row plus any container index entries owned by it, so global views don't show ghost rows. Boot-time BackfillWorkloads scans every project/stack/site and ensures each has a workload row. Idempotent — safe to run on every restart, recovers from a deleted/missing workload row. Behavior unchanged for existing call sites; the workloads table just starts being populated. Deployer / reconciler / consumer switchover land in the next commit.
337 lines
9.2 KiB
Go
337 lines
9.2 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const stackCols = `id, name, description, compose_project_name, status, error,
|
|
current_revision_id, created_at, updated_at`
|
|
|
|
// CreateStack inserts a new stack and returns it.
|
|
func (s *Store) CreateStack(st Stack) (Stack, error) {
|
|
st.ID = uuid.New().String()
|
|
st.CreatedAt = Now()
|
|
st.UpdatedAt = st.CreatedAt
|
|
if st.Status == "" {
|
|
st.Status = "stopped"
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO stacks (`+stackCols+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
|
|
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return Stack{}, fmt.Errorf("insert stack: %w", err)
|
|
}
|
|
if err := s.SyncStackWorkload(st); err != nil {
|
|
return Stack{}, fmt.Errorf("sync stack workload: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// GetStackByID returns a single stack by its ID.
|
|
func (s *Store) GetStackByID(id string) (Stack, error) {
|
|
st, err := scanStackRow(s.db.QueryRow(
|
|
`SELECT `+stackCols+` FROM stacks WHERE id = ?`, id,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return Stack{}, fmt.Errorf("query stack: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// GetAllStacks returns every stack ordered by name.
|
|
func (s *Store) GetAllStacks() ([]Stack, error) {
|
|
rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query stacks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Stack{}
|
|
for rows.Next() {
|
|
st, err := scanStackRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, st)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateStack updates the mutable metadata fields (name, description).
|
|
func (s *Store) UpdateStack(st Stack) error {
|
|
st.UpdatedAt = Now()
|
|
result, err := s.db.Exec(
|
|
`UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`,
|
|
st.Name, st.Description, st.UpdatedAt, st.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update stack: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
|
|
}
|
|
return s.SyncStackWorkload(st)
|
|
}
|
|
|
|
// UpdateStackStatus updates the deployment status + error fields.
|
|
func (s *Store) UpdateStackStatus(id, status, errMsg string) error {
|
|
now := Now()
|
|
result, err := s.db.Exec(
|
|
`UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`,
|
|
status, errMsg, now, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update stack status: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetStackCurrentRevision updates the current_revision_id pointer.
|
|
func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
|
|
now := Now()
|
|
result, err := s.db.Exec(
|
|
`UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`,
|
|
revisionID, now, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update stack revision pointer: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
|
|
// Workload row + container index entries are removed too.
|
|
func (s *Store) DeleteStack(id string) error {
|
|
result, err := s.db.Exec(`DELETE FROM stacks WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete stack: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
|
}
|
|
if w, err := s.GetWorkloadByRef(WorkloadKindStack, id); err == nil {
|
|
if err := s.DeleteContainersByWorkload(w.ID); err != nil {
|
|
return fmt.Errorf("delete stack containers: %w", err)
|
|
}
|
|
if err := s.DeleteWorkload(w.ID); err != nil {
|
|
return fmt.Errorf("delete stack workload: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanStackRow(row *sql.Row) (Stack, error) {
|
|
var st Stack
|
|
err := row.Scan(
|
|
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
|
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
|
)
|
|
return st, err
|
|
}
|
|
|
|
func scanStackRows(rows *sql.Rows) (Stack, error) {
|
|
var st Stack
|
|
err := rows.Scan(
|
|
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
|
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return Stack{}, fmt.Errorf("scan stack: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// --- Stack revisions ---
|
|
|
|
const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at`
|
|
|
|
// CreateStackRevision inserts a new revision with the next monotonic revision number.
|
|
func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) {
|
|
r.ID = uuid.New().String()
|
|
r.CreatedAt = Now()
|
|
if r.Status == "" {
|
|
r.Status = "pending"
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return StackRevision{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var next int
|
|
if err := tx.QueryRow(
|
|
`SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`,
|
|
r.StackID,
|
|
).Scan(&next); err != nil {
|
|
return StackRevision{}, fmt.Errorf("next revision: %w", err)
|
|
}
|
|
r.Revision = next
|
|
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO stack_revisions (`+stackRevisionCols+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt,
|
|
); err != nil {
|
|
return StackRevision{}, fmt.Errorf("insert revision: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return StackRevision{}, fmt.Errorf("commit revision: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// GetStackRevisionByID returns a single revision by ID.
|
|
func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) {
|
|
r, err := scanStackRevisionRow(s.db.QueryRow(
|
|
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return StackRevision{}, fmt.Errorf("query revision: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// GetStackRevisionsByStackID returns revisions newest-first.
|
|
func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ?
|
|
ORDER BY revision DESC`,
|
|
stackID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query revisions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []StackRevision{}
|
|
for rows.Next() {
|
|
r, err := scanStackRevisionRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateStackRevisionStatus updates status + deploy_id linkage.
|
|
func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error {
|
|
result, err := s.db.Exec(
|
|
`UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`,
|
|
status, deployID, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update revision status: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanStackRevisionRow(row *sql.Row) (StackRevision, error) {
|
|
var r StackRevision
|
|
err := row.Scan(
|
|
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
|
)
|
|
return r, err
|
|
}
|
|
|
|
func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) {
|
|
var r StackRevision
|
|
err := rows.Scan(
|
|
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return StackRevision{}, fmt.Errorf("scan revision: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// --- Stack deploys ---
|
|
|
|
const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at`
|
|
|
|
// CreateStackDeploy inserts a new deploy record.
|
|
func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) {
|
|
d.ID = uuid.New().String()
|
|
d.StartedAt = Now()
|
|
if d.Status == "" {
|
|
d.Status = "pending"
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO stack_deploys (`+stackDeployCols+`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt,
|
|
)
|
|
if err != nil {
|
|
return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// GetStackDeployByID returns a single deploy by ID.
|
|
func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) {
|
|
d, err := scanStackDeployRow(s.db.QueryRow(
|
|
`SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound)
|
|
}
|
|
if err != nil {
|
|
return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// UpdateStackDeploy updates status, log, error, finished_at.
|
|
func (s *Store) UpdateStackDeploy(d StackDeploy) error {
|
|
result, err := s.db.Exec(
|
|
`UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`,
|
|
d.Status, d.Log, d.Error, d.FinishedAt, d.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update stack deploy: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanStackDeployRow(row *sql.Row) (StackDeploy, error) {
|
|
var d StackDeploy
|
|
err := row.Scan(
|
|
&d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt,
|
|
)
|
|
return d, err
|
|
}
|