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
+102 -71
View File
@@ -1,84 +1,115 @@
package store
import "fmt"
import (
"database/sql"
"errors"
"fmt"
// SyncProjectWorkload upserts the Workload row paired with a project so that
// its name, notification config, and webhook secrets stay in sync. Called from
// CreateProject / UpdateProject / SetProject*Secret paths. Idempotent — safe
// to call when a workload row already exists for the (project, RefID) pair.
"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 {
existing, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err == nil {
existing.Name = p.Name
existing.NotificationURL = p.NotificationURL
existing.NotificationSecret = p.NotificationSecret
existing.WebhookSecret = p.WebhookSecret
existing.WebhookSigningSecret = p.WebhookSigningSecret
existing.WebhookRequireSignature = p.WebhookRequireSignature
return s.UpdateWorkload(existing)
}
_, err = s.CreateWorkload(Workload{
Kind: string(WorkloadKindProject),
RefID: p.ID,
Name: p.Name,
NotificationURL: p.NotificationURL,
NotificationSecret: p.NotificationSecret,
WebhookSecret: p.WebhookSecret,
WebhookSigningSecret: p.WebhookSigningSecret,
WebhookRequireSignature: p.WebhookRequireSignature,
})
if err != nil {
return fmt.Errorf("create project workload: %w", err)
}
return nil
return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name,
p.NotificationURL, p.NotificationSecret,
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
}
// SyncStackWorkload upserts the Workload row paired with a stack. Stacks
// don't (yet) carry their own notification or webhook config — those fields
// stay empty on the workload row until the stack model gains them.
// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStackWorkload(st Stack) error {
existing, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err == nil {
existing.Name = st.Name
return s.UpdateWorkload(existing)
}
_, err = s.CreateWorkload(Workload{
Kind: string(WorkloadKindStack),
RefID: st.ID,
Name: st.Name,
})
if err != nil {
return fmt.Errorf("create stack workload: %w", err)
}
return nil
return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
}
// SyncStaticSiteWorkload upserts the Workload row paired with a static site.
// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads.
func (s *Store) SyncStaticSiteWorkload(site StaticSite) error {
existing, err := s.GetWorkloadByRef(WorkloadKindSite, site.ID)
if err == nil {
existing.Name = site.Name
existing.NotificationURL = site.NotificationURL
existing.NotificationSecret = site.NotificationSecret
existing.WebhookSecret = site.WebhookSecret
existing.WebhookSigningSecret = site.WebhookSigningSecret
existing.WebhookRequireSignature = site.WebhookRequireSignature
return s.UpdateWorkload(existing)
}
_, err = s.CreateWorkload(Workload{
Kind: string(WorkloadKindSite),
RefID: site.ID,
Name: site.Name,
NotificationURL: site.NotificationURL,
NotificationSecret: site.NotificationSecret,
WebhookSecret: site.WebhookSecret,
WebhookSigningSecret: site.WebhookSigningSecret,
WebhookRequireSignature: site.WebhookRequireSignature,
})
if err != nil {
return fmt.Errorf("create static site workload: %w", err)
}
return nil
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