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>
112 lines
3.3 KiB
Go
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
|
|
}
|