Files
alexei.dolgolyov 8d6a527a2b 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>
2026-05-11 22:17:41 +03:00

112 lines
3.3 KiB
Go

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
}