cba2149aa9
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.
151 lines
5.2 KiB
Go
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
|
|
}
|