Files
tiny-forge/internal/api/workload_env.go
T
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

215 lines
6.4 KiB
Go

package api
import (
"errors"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/store"
)
// workloadEnvRow is the JSON shape returned to clients. Plaintext is
// redacted for encrypted entries — once a value is encrypted, the
// server treats it as write-only. To rotate, the operator submits a new
// value; to read, they have to look at the running container.
type workloadEnvRow struct {
ID string `json:"id"`
WorkloadID string `json:"workload_id"`
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Server) listWorkloadEnv(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
rows, err := s.store.ListWorkloadEnv(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "list workload env")
return
}
out := make([]workloadEnvRow, 0, len(rows))
for _, e := range rows {
row := workloadEnvRow{
ID: e.ID,
WorkloadID: e.WorkloadID,
Key: e.Key,
Encrypted: e.Encrypted,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.Encrypted {
row.Value = "" // write-only after encryption
} else {
row.Value = e.Value
}
out = append(out, row)
}
respondJSON(w, http.StatusOK, out)
}
// setWorkloadEnvRequest is the POST/PUT body. Encrypted=true causes the
// server to encrypt the value at rest with the global encryption key.
type setWorkloadEnvRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Encrypted bool `json:"encrypted"`
}
func (s *Server) setWorkloadEnv(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "get workload")
return
}
var req setWorkloadEnvRequest
if !decodeJSONStrict(w, r, &req) {
return
}
req.Key = strings.TrimSpace(req.Key)
if req.Key == "" {
respondError(w, http.StatusBadRequest, "key is required")
return
}
if !validEnvKey(req.Key) {
respondError(w, http.StatusBadRequest, "key must match [A-Za-z_][A-Za-z0-9_]*")
return
}
value := req.Value
if req.Encrypted && value != "" {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "encrypt value")
return
}
value = enc
}
row, err := s.store.SetWorkloadEnv(store.WorkloadEnv{
WorkloadID: id,
Key: req.Key,
Value: value,
Encrypted: req.Encrypted,
})
if err != nil {
slog.Error("set workload env", "workload", id, "key", req.Key, "error", err)
respondError(w, http.StatusInternalServerError, "set workload env")
return
}
respondJSON(w, http.StatusOK, workloadEnvRow{
ID: row.ID,
WorkloadID: row.WorkloadID,
Key: row.Key,
Value: "", // never echo even fresh writes — caller already has it
Encrypted: row.Encrypted,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) {
envID := chi.URLParam(r, "envID")
if err := s.store.DeleteWorkloadEnv(envID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload env")
return
}
respondError(w, http.StatusInternalServerError, "delete workload env")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
}
// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns
// the canonical URL + secret + signature-state flags. Lazily generates
// a secret if the workload row predates the column.
func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureWorkloadWebhookSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
slog.Error("ensure workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
return
}
row, err := s.store.GetWorkloadByID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate.
// Rotates the URL secret. The old secret is invalidated immediately —
// any external system still hitting the old URL gets a 404 on next call.
func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetWorkloadByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "workload")
return
}
respondError(w, http.StatusInternalServerError, "failed to get workload")
return
}
secret := generateWebhookSecret()
if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil {
slog.Error("rotate workload webhook secret", "workload", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
row, _ := s.store.GetWorkloadByID(id)
respondJSON(w, http.StatusOK, webhookURLResponse{
WebhookURL: "/api/webhook/workloads/" + secret,
WebhookSecret: secret,
HasSigningSecret: row.WebhookSigningSecret != "",
WebhookRequireSignature: row.WebhookRequireSignature,
})
}
// validEnvKey accepts POSIX-style env names. Rejects anything that would
// confuse Docker's env parser (=, spaces, control chars).
func validEnvKey(k string) bool {
if len(k) == 0 || len(k) > 256 {
return false
}
for i, ch := range k {
switch {
case ch >= 'A' && ch <= 'Z',
ch >= 'a' && ch <= 'z',
ch == '_':
continue
case (ch >= '0' && ch <= '9') && i > 0:
continue
default:
return false
}
}
return true
}