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