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.
This commit is contained in:
+78
-16
@@ -11,7 +11,9 @@ import (
|
||||
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.
|
||||
// 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()
|
||||
@@ -20,17 +22,25 @@ func (s *Store) CreateStack(st Stack) (Stack, error) {
|
||||
st.Status = "stopped"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
); 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)
|
||||
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
|
||||
}
|
||||
@@ -49,6 +59,26 @@ func (s *Store) GetStackByID(id string) (Stack, error) {
|
||||
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`)
|
||||
@@ -69,20 +99,34 @@ func (s *Store) GetAllStacks() ([]Stack, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
result, err := s.db.Exec(
|
||||
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, _ := result.RowsAffected()
|
||||
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)
|
||||
}
|
||||
return s.SyncStackWorkload(st)
|
||||
if err := SyncStackWorkloadTx(tx, st); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateStackStatus updates the deployment status + error fields.
|
||||
@@ -120,25 +164,43 @@ func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
|
||||
}
|
||||
|
||||
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
|
||||
// Workload row + container index entries are removed too.
|
||||
// Stack + workload + container index rows are dropped atomically.
|
||||
func (s *Store) DeleteStack(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM stacks WHERE id = ?`, id)
|
||||
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, _ := result.RowsAffected()
|
||||
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 w, err := s.GetWorkloadByRef(WorkloadKindStack, id); err == nil {
|
||||
if err := s.DeleteContainersByWorkload(w.ID); err != nil {
|
||||
|
||||
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 := s.DeleteWorkload(w.ID); err != nil {
|
||||
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
|
||||
return fmt.Errorf("delete stack workload: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func scanStackRow(row *sql.Row) (Stack, error) {
|
||||
|
||||
Reference in New Issue
Block a user