8d6a527a2b
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>
118 lines
3.7 KiB
Go
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
|
|
}
|