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:
2026-05-09 15:44:41 +03:00
parent d8ab22876f
commit cba2149aa9
30 changed files with 1227 additions and 509 deletions
+86 -79
View File
@@ -19,7 +19,8 @@ const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, br
created_at, updated_at`
// CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input.
// is generated automatically if one is not already set on the input. Site row
// + matching workload row are written in a single transaction.
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
site.ID = uuid.New().String()
site.CreatedAt = Now()
@@ -30,7 +31,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
}
_, err := s.db.Exec(
tx, err := s.db.Begin()
if err != nil {
return StaticSite{}, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
@@ -41,12 +48,14 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
); err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
}
if err := s.SyncStaticSiteWorkload(site); err != nil {
return StaticSite{}, fmt.Errorf("sync static site workload: %w", err)
if err := SyncStaticSiteWorkloadTx(tx, site); err != nil {
return StaticSite{}, err
}
if err := tx.Commit(); err != nil {
return StaticSite{}, fmt.Errorf("commit: %w", err)
}
return site, nil
}
@@ -110,12 +119,52 @@ func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite
return sites, rows.Err()
}
// updateStaticSiteAndSyncWorkloadTx wraps a parameterized UPDATE on
// static_sites with the workload sync, all inside a single transaction.
// updateSQL must end with `WHERE id=?`; args end with the site ID.
func (s *Store) updateStaticSiteAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
result, err := tx.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("update static site: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
row := tx.QueryRow(`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id)
current, err := scanStaticSiteRowFromQuery(row)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
if err := SyncStaticSiteWorkloadTx(tx, current); err != nil {
return err
}
return tx.Commit()
}
// scanStaticSiteRowFromQuery is a thin wrapper around scanStaticSiteRow that
// accepts a *sql.Row from either s.db or a transaction. Kept private so the
// public surface stays narrow.
func scanStaticSiteRowFromQuery(row *sql.Row) (StaticSite, error) {
return scanStaticSiteRow(row)
}
// UpdateStaticSite updates an existing static site's configuration fields.
// notification_secret is intentionally not updated here — use the dedicated
// SetStaticSiteNotificationSecret rotation helper.
func (s *Store) UpdateStaticSite(site StaticSite) error {
site.UpdatedAt = Now()
result, err := s.db.Exec(
return s.updateStaticSiteAndSyncWorkloadTx(site.ID,
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
@@ -127,18 +176,6 @@ func (s *Store) UpdateStaticSite(site StaticSite) error {
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.NotificationURL, site.UpdatedAt, site.ID,
)
if err != nil {
return fmt.Errorf("update static site: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", site.ID, ErrNotFound)
}
current, err := s.GetStaticSiteByID(site.ID)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
return s.SyncStaticSiteWorkload(current)
}
// UpdateStaticSiteStatus updates the deployment status fields.
@@ -220,26 +257,44 @@ func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
return routes, rows.Err()
}
// DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets.
// Workload row + container index entries are removed too.
// DeleteStaticSite removes a static site by ID. Cascading deletes handle
// secrets. Site + workload + container index rows are dropped atomically.
func (s *Store) DeleteStaticSite(id string) error {
result, err := s.db.Exec(`DELETE FROM static_sites 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(WorkloadKindSite), id,
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("lookup site workload: %w", err)
}
result, err := tx.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete static site: %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("static site %s: %w", id, ErrNotFound)
}
if w, err := s.GetWorkloadByRef(WorkloadKindSite, 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 static site 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 static site workload: %w", err)
}
}
return nil
return tx.Commit()
}
// scanStaticSiteRow scans a static site from a *sql.Row.
@@ -291,22 +346,10 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret.
// Pass an empty string to clear it (also implicitly disables enforcement).
func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error {
result, err := s.db.Exec(
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site webhook signing secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
current, err := s.GetStaticSiteByID(id)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
return s.SyncStaticSiteWorkload(current)
}
// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or
@@ -316,44 +359,20 @@ func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) er
if require {
v = 1
}
result, err := s.db.Exec(
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site webhook require_signature: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
current, err := s.GetStaticSiteByID(id)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
return s.SyncStaticSiteWorkload(current)
}
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
// signing secret. Empty string disables HMAC signing for this site
// (notifications still send unsigned, falling through to global resolution).
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
current, err := s.GetStaticSiteByID(id)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
return s.SyncStaticSiteWorkload(current)
}
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
@@ -411,22 +430,10 @@ func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error)
// SetStaticSiteWebhookSecret assigns a webhook secret to a static site.
// Pass an empty string to disable webhook access for the site.
func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
return s.updateStaticSiteAndSyncWorkloadTx(id,
`UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site webhook secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
current, err := s.GetStaticSiteByID(id)
if err != nil {
return fmt.Errorf("reread static site for workload sync: %w", err)
}
return s.SyncStaticSiteWorkload(current)
}
// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site,