Files
tiny-forge/internal/store/stacks.go
T
alexei.dolgolyov 75424a5f25
Build / build (push) Successful in 10m42s
feat: docker-compose stacks with Forge-themed UI
Adds a new Stacks feature: upload/edit docker-compose YAML,
deploy as atomic units, browse revisions, roll back, and
stream logs. Backend in internal/stack + internal/api/stacks.go,
persistent storage in internal/store/stacks.go.

Stacks pages (list, new, detail) use a modern Forge aesthetic —
Instrument Serif display type, JetBrains Mono for meta/code,
indigo ember accents, dot-grid hero, registration marks on
hover, terminal panel for logs. Palette is sourced from the
app's existing design tokens so the feature remains consistent
with the rest of Tinyforge.

Fonts self-hosted via @fontsource/instrument-serif and
@fontsource/jetbrains-mono to satisfy the strict CSP.
2026-04-16 03:48:37 +03:00

325 lines
8.7 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)
}
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 nil
}
// 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.
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)
}
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
}