Files
tiny-forge/internal/store/workload_sync.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

151 lines
5.2 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so
// CRUD callers can pass in either a transaction or the raw DB handle. Keeps
// the sync logic atomic with the parent row when wrapped in a Begin/Commit.
type dbExec interface {
Exec(query string, args ...any) (sql.Result, error)
QueryRow(query string, args ...any) *sql.Row
}
// syncWorkloadTx is the shared upsert path used by every kind-specific
// sync helper. Caller passes the kind, ref, and the projection of fields
// that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE
// constraint to decide INSERT vs UPDATE.
func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error {
now := Now()
requireInt := 0
if requireSig {
requireInt = 1
}
var existingID string
err := ex.QueryRow(
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
string(kind), refID,
).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
_, err := ex.Exec(
`INSERT INTO workloads (id, kind, ref_id, name, app_id,
notification_url, notification_secret,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at)
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`,
uuid.New().String(), string(kind), refID, name,
notifURL, notifSecret, hookSecret, signSecret, requireInt,
now, now,
)
if err != nil {
return fmt.Errorf("insert %s workload: %w", kind, err)
}
return nil
}
if err != nil {
return fmt.Errorf("lookup %s workload: %w", kind, err)
}
_, err = ex.Exec(
`UPDATE workloads SET name=?,
notification_url=?, notification_secret=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID,
)
if err != nil {
return fmt.Errorf("update %s workload: %w", kind, err)
}
return nil
}
// SyncProjectWorkloadTx upserts the workload row paired with a project inside
// the caller's transaction. Used by CreateProject / UpdateProject /
// SetProject*Secret so the parent UPDATE and the workload sync share atomicity.
func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error {
return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkloadTx upserts the workload row paired with a stack inside the
// caller's transaction. Stacks don't carry notification or webhook config yet.
func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error {
return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkloadTx upserts the workload row paired with a static site
// inside the caller's transaction.
func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error {
return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// SyncProjectWorkload is the non-transactional convenience used by
// BackfillWorkloads (a boot-time, single-row, idempotent recovery pass).
// CRUD paths must use SyncProjectWorkloadTx instead, with their parent
// UPDATE inside the same transaction.
func (s *Store) SyncProjectWorkload(p Project) error {
return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStackWorkload(st Stack) error {
return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStaticSiteWorkload(site StaticSite) error {
return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name,
site.NotificationURL, site.NotificationSecret,
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
}
// BackfillWorkloads scans every project / stack / static_site row and ensures
// each has a matching workload row. Called once at boot before HTTP starts so
// any pre-Workload-refactor data is upgraded transparently. Idempotent.
func (s *Store) BackfillWorkloads() error {
projects, err := s.GetAllProjects()
if err != nil {
return fmt.Errorf("backfill: list projects: %w", err)
}
for _, p := range projects {
if err := s.SyncProjectWorkload(p); err != nil {
return fmt.Errorf("backfill project %s: %w", p.ID, err)
}
}
stacks, err := s.GetAllStacks()
if err != nil {
return fmt.Errorf("backfill: list stacks: %w", err)
}
for _, st := range stacks {
if err := s.SyncStackWorkload(st); err != nil {
return fmt.Errorf("backfill stack %s: %w", st.ID, err)
}
}
sites, err := s.GetAllStaticSites()
if err != nil {
return fmt.Errorf("backfill: list static sites: %w", err)
}
for _, site := range sites {
if err := s.SyncStaticSiteWorkload(site); err != nil {
return fmt.Errorf("backfill static site %s: %w", site.ID, err)
}
}
return nil
}