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