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

118 lines
3.7 KiB
Go

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
}