refactor(workload): plugin architecture wave + apps UI + volume scopes

Completes the workload-first refactor's plugin layer:

- internal/workload/plugin/ — Source/Trigger plugin contract,
  registry, types (Workload, DeploymentIntent, InboundEvent,
  PublicFace). Self-registering init() pattern + blank-import
  in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
  compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
  seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
  volumes, chain (parent/children), promote-from. hooks.go
  serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
  workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
  the static source plugin to the legacy staticsite.Manager
  (deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
  /apps/[id] detail with kind-aware compose / image / static
  forms (Advanced JSON toggle), env panel, volumes panel,
  webhook panel, chain panel, manual deploy.

Volume scope generalization (v2 resolver):

- internal/volume.ResolveWorkloadPath (workload-keyed, sits
  next to legacy ResolvePath). Honors all VolumeScope values:
  absolute, ephemeral, instance, stage, project, project_named,
  named. internal/workload/plugin/source/image/image.go
  computeMounts wires settings + imageTag through. Coverage in
  internal/volume/resolver_test.go (portable Linux/Windows via
  t.TempDir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:17:41 +03:00
parent f42b21a2b9
commit 8d6a527a2b
41 changed files with 9482 additions and 18 deletions
+27 -10
View File
@@ -15,7 +15,7 @@ import (
const containerColumns = `id, workload_id, workload_kind, role, stage_id, container_id,
image_ref, image_tag, host, state, port,
subdomain, proxy_route_id, npm_proxy_id,
last_seen_at, created_at, updated_at`
last_seen_at, extra_json, created_at, updated_at`
func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) {
var c Container
@@ -23,7 +23,7 @@ func scanContainer(scanner interface{ Scan(...any) error }) (Container, error) {
&c.ID, &c.WorkloadID, &c.WorkloadKind, &c.Role, &c.StageID, &c.ContainerID,
&c.ImageRef, &c.ImageTag, &c.Host, &c.State, &c.Port,
&c.Subdomain, &c.ProxyRouteID, &c.NpmProxyID,
&c.LastSeenAt, &c.CreatedAt, &c.UpdatedAt,
&c.LastSeenAt, &c.ExtraJSON, &c.CreatedAt, &c.UpdatedAt,
)
return c, err
}
@@ -39,14 +39,17 @@ func (s *Store) CreateContainer(c Container) (Container, error) {
}
c.CreatedAt = Now()
c.UpdatedAt = c.CreatedAt
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
_, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt,
c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
)
if err != nil {
return Container{}, fmt.Errorf("insert container: %w", err)
@@ -71,11 +74,14 @@ func (s *Store) UpsertContainer(c Container) error {
if c.CreatedAt == "" {
c.CreatedAt = c.UpdatedAt
}
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
// SQLite UPSERT — INSERT...ON CONFLICT(id) DO UPDATE.
_, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
workload_id=excluded.workload_id,
workload_kind=excluded.workload_kind,
@@ -91,11 +97,12 @@ func (s *Store) UpsertContainer(c Container) error {
proxy_route_id=excluded.proxy_route_id,
npm_proxy_id=excluded.npm_proxy_id,
last_seen_at=excluded.last_seen_at,
extra_json=excluded.extra_json,
updated_at=excluded.updated_at`,
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt,
c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
)
if err != nil {
return fmt.Errorf("upsert container: %w", err)
@@ -119,10 +126,17 @@ func (s *Store) ReconcileContainer(c Container) error {
if c.CreatedAt == "" {
c.CreatedAt = c.UpdatedAt
}
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
// extra_json is deliberately NOT in the ON CONFLICT SET clause: the
// reconciler can't observe per-face route IDs from Docker, and
// stomping the deployer's writes would orphan proxy routes at
// teardown.
_, err := s.db.Exec(
`INSERT INTO containers (`+containerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
container_id=excluded.container_id,
image_ref=excluded.image_ref,
@@ -133,7 +147,7 @@ func (s *Store) ReconcileContainer(c Container) error {
c.ID, c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.CreatedAt, c.UpdatedAt,
c.LastSeenAt, c.ExtraJSON, c.CreatedAt, c.UpdatedAt,
)
if err != nil {
return fmt.Errorf("reconcile container: %w", err)
@@ -335,16 +349,19 @@ func (s *Store) ListContainers(f ContainerFilter) ([]Container, error) {
// Use this from the deployer when proxy / subdomain assignments change.
func (s *Store) UpdateContainer(c Container) error {
c.UpdatedAt = Now()
if c.ExtraJSON == "" {
c.ExtraJSON = "{}"
}
result, err := s.db.Exec(
`UPDATE containers SET workload_id=?, workload_kind=?, role=?, stage_id=?, container_id=?,
image_ref=?, image_tag=?, host=?, state=?, port=?,
subdomain=?, proxy_route_id=?, npm_proxy_id=?,
last_seen_at=?, updated_at=?
last_seen_at=?, extra_json=?, updated_at=?
WHERE id=?`,
c.WorkloadID, c.WorkloadKind, c.Role, c.StageID, c.ContainerID,
c.ImageRef, c.ImageTag, c.Host, c.State, c.Port,
c.Subdomain, c.ProxyRouteID, c.NpmProxyID,
c.LastSeenAt, c.UpdatedAt, c.ID,
c.LastSeenAt, c.ExtraJSON, c.UpdatedAt, c.ID,
)
if err != nil {
return fmt.Errorf("update container: %w", err)
+48
View File
@@ -18,6 +18,54 @@ const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, br
notification_url, notification_secret,
created_at, updated_at`
// UpsertStaticSiteWithID inserts or replaces a static site, keeping the
// caller-supplied ID. Used by the plugin static-source Backend adapter
// to keep a phantom row keyed on the workload ID so staticsite.Manager
// (which reads from this table) can serve plugin-native workloads
// without being refactored. Skips workload-row sync since the caller
// already owns the workload row.
func (s *Store) UpsertStaticSiteWithID(site StaticSite) error {
if site.ID == "" {
return fmt.Errorf("UpsertStaticSiteWithID: id is required")
}
if site.WebhookSecret == "" {
site.WebhookSecret = generateWebhookSecret()
}
if site.SyncTrigger == "" {
site.SyncTrigger = "manual"
}
if site.Mode == "" {
site.Mode = "static"
}
if site.Branch == "" {
site.Branch = "main"
}
if site.Status == "" {
site.Status = "idle"
}
now := Now()
site.UpdatedAt = now
if site.CreatedAt == "" {
site.CreatedAt = now
}
_, err := s.db.Exec(
`INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return fmt.Errorf("upsert static site: %w", err)
}
return nil
}
// CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input. Site row
// + matching workload row are written in a single transaction.
+111
View File
@@ -0,0 +1,111 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// SetWorkloadEnv upserts a single env var for the workload. Uses
// (workload_id, key) as the natural key — duplicate keys collapse onto
// the same row instead of accumulating.
func (s *Store) SetWorkloadEnv(env WorkloadEnv) (WorkloadEnv, error) {
if env.WorkloadID == "" || env.Key == "" {
return WorkloadEnv{}, fmt.Errorf("workload_env: workload_id and key are required")
}
now := Now()
if env.ID == "" {
env.ID = uuid.New().String()
}
env.UpdatedAt = now
// Try INSERT first; on UNIQUE violation, fall through to UPDATE so the
// row's ID + created_at survive.
_, err := s.db.Exec(
`INSERT INTO workload_env (id, workload_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workload_id, key) DO UPDATE SET
value = excluded.value,
encrypted = excluded.encrypted,
updated_at = excluded.updated_at`,
env.ID, env.WorkloadID, env.Key, env.Value, BoolToInt(env.Encrypted),
now, now,
)
if err != nil {
return WorkloadEnv{}, fmt.Errorf("upsert workload env: %w", err)
}
// Re-read so the caller gets the canonical row (ID may differ when
// the conflict path took over an older row).
row, err := s.getWorkloadEnvByKey(env.WorkloadID, env.Key)
if err != nil {
return WorkloadEnv{}, err
}
return row, nil
}
// ListWorkloadEnv returns every env var for a workload, ordered by key.
func (s *Store) ListWorkloadEnv(workloadID string) ([]WorkloadEnv, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, key, value, encrypted, created_at, updated_at
FROM workload_env WHERE workload_id = ? ORDER BY key`, workloadID,
)
if err != nil {
return nil, fmt.Errorf("query workload env: %w", err)
}
defer rows.Close()
out := []WorkloadEnv{}
for rows.Next() {
env, err := scanWorkloadEnvRows(rows)
if err != nil {
return nil, err
}
out = append(out, env)
}
return out, rows.Err()
}
// DeleteWorkloadEnv removes one env var by ID.
func (s *Store) DeleteWorkloadEnv(id string) error {
result, err := s.db.Exec(`DELETE FROM workload_env WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete workload env: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload env %s: %w", id, ErrNotFound)
}
return nil
}
// getWorkloadEnvByKey is the upsert's re-read helper.
func (s *Store) getWorkloadEnvByKey(workloadID, key string) (WorkloadEnv, error) {
var env WorkloadEnv
var enc int
err := s.db.QueryRow(
`SELECT id, workload_id, key, value, encrypted, created_at, updated_at
FROM workload_env WHERE workload_id = ? AND key = ?`, workloadID, key,
).Scan(&env.ID, &env.WorkloadID, &env.Key, &env.Value, &enc,
&env.CreatedAt, &env.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return WorkloadEnv{}, fmt.Errorf("workload env (%s,%s): %w", workloadID, key, ErrNotFound)
}
if err != nil {
return WorkloadEnv{}, fmt.Errorf("query workload env: %w", err)
}
env.Encrypted = enc != 0
return env, nil
}
func scanWorkloadEnvRows(rows *sql.Rows) (WorkloadEnv, error) {
var env WorkloadEnv
var enc int
if err := rows.Scan(&env.ID, &env.WorkloadID, &env.Key, &env.Value, &enc,
&env.CreatedAt, &env.UpdatedAt); err != nil {
return WorkloadEnv{}, fmt.Errorf("scan workload env: %w", err)
}
env.Encrypted = enc != 0
return env, nil
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"strings"
"testing"
)
func mustCreateWorkload(t *testing.T, s *Store, name string) Workload {
t.Helper()
w, err := s.CreateWorkload(Workload{
Name: name,
Kind: "plugin",
RefID: name,
SourceKind: "image",
TriggerKind: "manual",
})
if err != nil {
t.Fatalf("CreateWorkload(%s): %v", name, err)
}
return w
}
func TestSetWorkloadEnvUpsertSameKey(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "envwl")
first, err := s.SetWorkloadEnv(WorkloadEnv{
WorkloadID: w.ID, Key: "DB_URL", Value: "v1",
})
if err != nil {
t.Fatalf("first set: %v", err)
}
second, err := s.SetWorkloadEnv(WorkloadEnv{
WorkloadID: w.ID, Key: "DB_URL", Value: "v2", Encrypted: true,
})
if err != nil {
t.Fatalf("second set: %v", err)
}
// Same row ID — UPSERT must preserve identity, not accumulate rows.
if first.ID != second.ID {
t.Errorf("upsert produced new row: first=%s second=%s", first.ID, second.ID)
}
all, err := s.ListWorkloadEnv(w.ID)
if err != nil {
t.Fatalf("ListWorkloadEnv: %v", err)
}
if len(all) != 1 {
t.Fatalf("expected 1 row after upsert, got %d", len(all))
}
if all[0].Value != "v2" || !all[0].Encrypted {
t.Errorf("expected upserted value+encrypted, got value=%q encrypted=%v",
all[0].Value, all[0].Encrypted)
}
}
func TestSetWorkloadEnvValidation(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "validate-wl")
if _, err := s.SetWorkloadEnv(WorkloadEnv{Key: "X"}); err == nil {
t.Fatal("expected error when WorkloadID missing")
}
if _, err := s.SetWorkloadEnv(WorkloadEnv{WorkloadID: w.ID}); err == nil {
t.Fatal("expected error when Key missing")
}
}
func TestDeleteWorkloadEnv(t *testing.T) {
s := newTestStore(t)
w := mustCreateWorkload(t, s, "delete-wl")
row, _ := s.SetWorkloadEnv(WorkloadEnv{WorkloadID: w.ID, Key: "K", Value: "V"})
if err := s.DeleteWorkloadEnv(row.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if err := s.DeleteWorkloadEnv(row.ID); err == nil {
t.Fatal("expected ErrNotFound on second delete")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected not-found error, got %v", err)
}
}
func TestListChildrenByParent(t *testing.T) {
s := newTestStore(t)
parent := mustCreateWorkload(t, s, "parent")
other := mustCreateWorkload(t, s, "other-root")
// Two children of parent, plus one root unrelated.
for _, name := range []string{"child-a", "child-b"} {
c, err := s.CreateWorkload(Workload{
Name: name,
Kind: "plugin",
RefID: name,
SourceKind: "image",
TriggerKind: "manual",
ParentWorkloadID: parent.ID,
})
if err != nil {
t.Fatalf("create child %s: %v", name, err)
}
_ = c
}
got, err := s.ListChildrenByParent(parent.ID)
if err != nil {
t.Fatalf("ListChildrenByParent: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 children, got %d", len(got))
}
if got[0].Name >= got[1].Name {
t.Errorf("expected name-ordered output, got %q then %q", got[0].Name, got[1].Name)
}
// The unrelated workload must not appear.
for _, c := range got {
if c.ID == other.ID {
t.Errorf("ListChildrenByParent leaked unrelated workload %s", other.ID)
}
}
// Empty parent returns empty slice, not error.
empty, err := s.ListChildrenByParent("")
if err != nil {
t.Fatalf("empty parent should not error: %v", err)
}
if len(empty) != 0 {
t.Errorf("empty parent should return 0 rows, got %d", len(empty))
}
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// SetWorkloadVolume upserts a volume mount keyed by (workload_id, target).
// The target is the natural key — re-using a target replaces the row
// rather than accumulating duplicates that would conflict at mount time.
func (s *Store) SetWorkloadVolume(v WorkloadVolume) (WorkloadVolume, error) {
if v.WorkloadID == "" || v.Target == "" {
return WorkloadVolume{}, fmt.Errorf("workload_volume: workload_id and target are required")
}
if v.Scope == "" {
v.Scope = string(VolumeScopeAbsolute)
}
if !IsValidVolumeScope(v.Scope) {
return WorkloadVolume{}, fmt.Errorf("workload_volume: invalid scope %q", v.Scope)
}
if v.ID == "" {
v.ID = uuid.New().String()
}
now := Now()
if _, err := s.db.Exec(
`INSERT INTO workload_volumes (id, workload_id, source, target, scope, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workload_id, target) DO UPDATE SET
source = excluded.source,
scope = excluded.scope,
name = excluded.name,
updated_at = excluded.updated_at`,
v.ID, v.WorkloadID, v.Source, v.Target, v.Scope, v.Name, now, now,
); err != nil {
return WorkloadVolume{}, fmt.Errorf("upsert workload volume: %w", err)
}
return s.getWorkloadVolumeByTarget(v.WorkloadID, v.Target)
}
// ListWorkloadVolumes returns every mount for the given workload, ordered
// by target so the UI rendering is stable across requests.
func (s *Store) ListWorkloadVolumes(workloadID string) ([]WorkloadVolume, error) {
rows, err := s.db.Query(
`SELECT id, workload_id, source, target, scope, name, created_at, updated_at
FROM workload_volumes WHERE workload_id = ? ORDER BY target`, workloadID,
)
if err != nil {
return nil, fmt.Errorf("query workload volumes: %w", err)
}
defer rows.Close()
out := []WorkloadVolume{}
for rows.Next() {
v, err := scanWorkloadVolume(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// DeleteWorkloadVolume removes one mount by ID.
func (s *Store) DeleteWorkloadVolume(id string) error {
result, err := s.db.Exec(`DELETE FROM workload_volumes WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete workload volume: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload volume %s: %w", id, ErrNotFound)
}
return nil
}
func (s *Store) getWorkloadVolumeByTarget(workloadID, target string) (WorkloadVolume, error) {
var v WorkloadVolume
err := s.db.QueryRow(
`SELECT id, workload_id, source, target, scope, name, created_at, updated_at
FROM workload_volumes WHERE workload_id = ? AND target = ?`, workloadID, target,
).Scan(&v.ID, &v.WorkloadID, &v.Source, &v.Target, &v.Scope, &v.Name, &v.CreatedAt, &v.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return WorkloadVolume{}, fmt.Errorf("workload volume (%s,%s): %w", workloadID, target, ErrNotFound)
}
if err != nil {
return WorkloadVolume{}, fmt.Errorf("query workload volume: %w", err)
}
return v, nil
}
func scanWorkloadVolume(rows *sql.Rows) (WorkloadVolume, error) {
var v WorkloadVolume
if err := rows.Scan(&v.ID, &v.WorkloadID, &v.Source, &v.Target, &v.Scope, &v.Name,
&v.CreatedAt, &v.UpdatedAt); err != nil {
return WorkloadVolume{}, fmt.Errorf("scan workload volume: %w", err)
}
return v, nil
}
// normalizeAbsolutePath is a defensive helper for volume source paths in
// "absolute" scope. Rejects path-traversal segments so a malicious client
// can't escape an allow-listed prefix at the API layer. The actual
// allowed-paths check lives in settings.AllowedVolumePaths and remains
// the policy authority.
func normalizeAbsolutePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if strings.Contains(p, "..") {
return ""
}
return p
}
+95 -2
View File
@@ -9,6 +9,8 @@ import (
)
const workloadColumns = `id, kind, ref_id, name, app_id,
source_kind, source_config, trigger_kind, trigger_config,
public_faces, parent_workload_id,
notification_url, notification_secret,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at`
@@ -17,6 +19,8 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
var w Workload
err := scanner.Scan(
&w.ID, &w.Kind, &w.RefID, &w.Name, &w.AppID,
&w.SourceKind, &w.SourceConfig, &w.TriggerKind, &w.TriggerConfig,
&w.PublicFaces, &w.ParentWorkloadID,
&w.NotificationURL, &w.NotificationSecret,
&w.WebhookSecret, &w.WebhookSigningSecret, &w.WebhookRequireSignature,
&w.CreatedAt, &w.UpdatedAt,
@@ -33,10 +37,21 @@ func (s *Store) CreateWorkload(w Workload) (Workload, error) {
w.CreatedAt = Now()
w.UpdatedAt = w.CreatedAt
if w.SourceConfig == "" {
w.SourceConfig = "{}"
}
if w.TriggerConfig == "" {
w.TriggerConfig = "{}"
}
if w.PublicFaces == "" {
w.PublicFaces = "[]"
}
_, err := s.db.Exec(
`INSERT INTO workloads (`+workloadColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.Kind, w.RefID, w.Name, w.AppID,
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
w.PublicFaces, w.ParentWorkloadID,
w.NotificationURL, w.NotificationSecret,
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
w.CreatedAt, w.UpdatedAt,
@@ -128,16 +143,30 @@ func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
}
// UpdateWorkload updates the mutable fields of a workload (name, app_id,
// notification config, webhook config). Kind and RefID are immutable post-create.
// source/trigger config, public faces, parent chain, notification + webhook
// config). Kind and RefID are immutable post-create.
func (s *Store) UpdateWorkload(w Workload) error {
w.UpdatedAt = Now()
if w.SourceConfig == "" {
w.SourceConfig = "{}"
}
if w.TriggerConfig == "" {
w.TriggerConfig = "{}"
}
if w.PublicFaces == "" {
w.PublicFaces = "[]"
}
result, err := s.db.Exec(
`UPDATE workloads SET name=?, app_id=?,
source_kind=?, source_config=?, trigger_kind=?, trigger_config=?,
public_faces=?, parent_workload_id=?,
notification_url=?, notification_secret=?,
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
updated_at=?
WHERE id=?`,
w.Name, w.AppID,
w.SourceKind, w.SourceConfig, w.TriggerKind, w.TriggerConfig,
w.PublicFaces, w.ParentWorkloadID,
w.NotificationURL, w.NotificationSecret,
w.WebhookSecret, w.WebhookSigningSecret, BoolToInt(w.WebhookRequireSignature),
w.UpdatedAt, w.ID,
@@ -173,6 +202,70 @@ func (s *Store) DeleteWorkload(id string) error {
return nil
}
// ListChildrenByParent returns every workload whose parent_workload_id
// equals the given id. Used to render the stages chain ("dev → staging
// → prod") on /apps/[id] without forcing a separate stages table.
//
// Returns rows ordered by name for a stable UI.
func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) {
if parentID == "" {
return []Workload{}, nil
}
rows, err := s.db.Query(
`SELECT `+workloadColumns+` FROM workloads WHERE parent_workload_id = ? ORDER BY name`,
parentID,
)
if err != nil {
return nil, fmt.Errorf("query workload children: %w", err)
}
defer rows.Close()
out := []Workload{}
for rows.Next() {
w, err := scanWorkload(rows)
if err != nil {
return nil, fmt.Errorf("scan child workload: %w", err)
}
out = append(out, w)
}
return out, rows.Err()
}
// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass
// empty to disable inbound webhooks for this workload.
func (s *Store) SetWorkloadWebhookSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("update workload webhook_secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureWorkloadWebhookSecret returns the current secret, generating one
// lazily for workloads that predate the column. Mirrors the project /
// site equivalents.
func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) {
w, err := s.GetWorkloadByID(id)
if err != nil {
return "", err
}
if w.WebhookSecret != "" {
return w.WebhookSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetWorkloadWebhookSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
// Idempotent — returns nil if no row exists, since the kind-specific Delete
// callers don't always know whether a workload row was created.