Files
tiny-forge/internal/store/stacks.go
T
alexei.dolgolyov cba2149aa9 refactor(workload): finalize containers index + post-review hardening
Wraps up the workload refactor with the fixes that came out of the multi-agent
code review (see docs/plans/workload-refactor.md "What actually shipped").

Backend:
- store.ReconcileContainer: separate write path so the 30s reconciler tick no
  longer overwrites deployer-owned fields (subdomain, proxy_route_id,
  npm_proxy_id, image_tag).
- Container.stage_id column + index; ListProxyRoutes / ListContainersByStageID
  join via stage_id (survives stage rename), with legacy fallback to
  (project_id, role=stage_name).
- Reconciler: workload-existence check (rejects forged tinyforge.workload.id
  labels), skips inventing project-kind rows, child-context cancel before
  wg.Wait() on shutdown.
- Transactional CRUD across projects / stacks / static_sites: parent UPDATE
  and workload sync land in one transaction so secret rotations are durable.
- Webhook routing reads exclusively through workloads.webhook_secret; legacy
  GetProjectByWebhookSecret / GetStaticSiteByWebhookSecret fallback removed.
- store.GetStackByComposeProjectName + indexed lookup (no more full-table
  stack scan per compose container per tick).
- store.ListMissingSweepRows: filtered query for the missing-sweep.
- /api/instances/* handlers verify (workload_id, role) match URL
  (project_id, stage_name) before mutating — closes the cross-project
  hijack the security review flagged.
- extra_json no longer referenced from Go (column kept on disk for now).

Frontend:
- WorkloadContainers.svelte: generic detail-page panel reusable by stack and
  site detail pages.
- Containers page polish: client-side kind/state filters over an unfiltered
  fetch, URL-synced filters, race-safe loads via sequence number, EN+RU i18n,
  sidebar counter via navCounts.containers.

Misc:
- scripts/dev-server.sh: tolerate empty netstat grep result.
- .gitignore: ignore docker-watcher binaries, .claude/worktrees/, .facts-sync.json.
2026-05-09 15:44:41 +03:00

399 lines
11 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. Stack row + matching
// workload row are written in a single transaction so a partial failure
// leaves no orphan.
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"
}
tx, err := s.db.Begin()
if err != nil {
return Stack{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO stacks (`+stackCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
); err != nil {
return Stack{}, fmt.Errorf("insert stack: %w", err)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return Stack{}, err
}
if err := tx.Commit(); err != nil {
return Stack{}, fmt.Errorf("commit: %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
}
// GetStackByComposeProjectName looks up a stack by its compose project name.
// Compose project names are unique per the stacks table schema, so this is an
// O(1) index lookup. Used by the reconciler to resolve compose-managed
// containers without scanning every stack.
func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) {
if name == "" {
return Stack{}, ErrNotFound
}
st, err := scanStackRow(s.db.QueryRow(
`SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name,
))
if errors.Is(err, sql.ErrNoRows) {
return Stack{}, ErrNotFound
}
if err != nil {
return Stack{}, fmt.Errorf("query stack by compose project: %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).
// Atomic: stack row UPDATE and workload row sync share a transaction so the
// workload row's name never lags after a rename.
func (s *Store) UpdateStack(st Stack) error {
st.UpdatedAt = Now()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.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, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
}
if err := SyncStackWorkloadTx(tx, st); err != nil {
return err
}
return tx.Commit()
}
// 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.
// Stack + workload + container index rows are dropped atomically.
func (s *Store) DeleteStack(id string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
var workloadID string
if err := tx.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(WorkloadKindStack), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup stack workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete stack: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
}
if workloadID != "" {
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack containers: %w", err)
}
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
return fmt.Errorf("delete stack workload: %w", err)
}
}
return tx.Commit()
}
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
}