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:
+102
-71
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user