feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s

The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
This commit is contained in:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
-405
View File
@@ -1,405 +0,0 @@
package stack
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/store"
)
// Manager orchestrates the stack deployment pipeline: validate YAML, persist
// a revision, write YAML to disk, run `docker compose up`, update status.
type Manager struct {
store *store.Store
compose *Compose
eventBus *events.Bus
workDir string // where per-stack YAML files are written
}
// NewManager constructs a stack Manager. workDir is the directory where
// per-stack YAML files are written; it is created if missing.
func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) {
if workDir == "" {
workDir = filepath.Join(os.TempDir(), "tinyforge-stacks")
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return nil, fmt.Errorf("create stack workdir: %w", err)
}
return &Manager{
store: st,
compose: compose,
eventBus: eventBus,
workDir: workDir,
}, nil
}
// Available reports whether the underlying `docker compose` CLI is usable.
func (m *Manager) Available(ctx context.Context) error {
return m.compose.Available(ctx)
}
// Create inserts a new stack + its initial revision. Does NOT deploy.
func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) {
if strings.TrimSpace(name) == "" {
return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required")
}
spec, err := Parse(yamlText)
if err != nil {
return store.Stack{}, store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.Stack{}, store.StackRevision{}, err
}
st := store.Stack{
Name: name,
Description: description,
ComposeProjectName: composeProjectName(name),
Status: "stopped",
}
st, err = m.store.CreateStack(st)
if err != nil {
return store.Stack{}, store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: st.ID,
YAML: yamlText,
Author: author,
})
if err != nil {
// Best-effort cleanup of the stack row.
_ = m.store.DeleteStack(st.ID)
return store.Stack{}, store.StackRevision{}, err
}
return st, rev, nil
}
// Deploy brings up the stack for the given revision. Updates stack + revision
// status transitions: deploying → running | failed. Blocking.
func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
rev, err := m.store.GetStackRevisionByID(revisionID)
if err != nil {
return err
}
if rev.StackID != stackID {
return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID)
}
deploy, err := m.store.CreateStackDeploy(store.StackDeploy{
StackID: stackID,
RevisionID: revisionID,
Status: "deploying",
})
if err != nil {
return err
}
_ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID)
m.setStatus(st, "deploying", "")
yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML)
if err != nil {
m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err))
return err
}
out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath)
if upErr != nil {
m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out))
return upErr
}
// Success.
deploy.Status = "success"
deploy.Log = out
deploy.FinishedAt = store.Now()
_ = m.store.UpdateStackDeploy(deploy)
_ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID)
_ = m.store.SetStackCurrentRevision(st.ID, rev.ID)
m.setStatus(st, "running", "")
m.syncContainerRows(ctx, st, yamlPath)
return nil
}
// NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it.
func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
spec, err := Parse(yamlText)
if err != nil {
return store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: stackID,
YAML: yamlText,
Author: author,
})
if err != nil {
return store.StackRevision{}, err
}
if err := m.Deploy(ctx, stackID, rev.ID); err != nil {
return rev, err
}
return rev, nil
}
// NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine.
// Returns the created revision immediately.
func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
spec, err := Parse(yamlText)
if err != nil {
return store.StackRevision{}, err
}
if err := Validate(spec); err != nil {
return store.StackRevision{}, err
}
rev, err := m.store.CreateStackRevision(store.StackRevision{
StackID: stackID,
YAML: yamlText,
Author: author,
})
if err != nil {
return store.StackRevision{}, err
}
go func(stackID, revID string) {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := m.Deploy(bgCtx, stackID, revID); err != nil {
slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err)
}
}(stackID, rev.ID)
return rev, nil
}
// RollbackAsync creates a copy-revision from a target and deploys asynchronously.
func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
target, err := m.store.GetStackRevisionByID(targetRevisionID)
if err != nil {
return store.StackRevision{}, err
}
if target.StackID != stackID {
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
}
return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
}
// Rollback creates a new revision whose YAML is copied from the given prior
// revision, then deploys it. Keeps history append-only.
func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
target, err := m.store.GetStackRevisionByID(targetRevisionID)
if err != nil {
return store.StackRevision{}, err
}
if target.StackID != stackID {
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
}
return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
}
// Stop runs `docker compose stop` without removing containers.
func (m *Manager) Stop(ctx context.Context, stackID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil {
return err
}
m.setStatus(st, "stopped", "")
m.markStackContainersState(stackID, "stopped")
return nil
}
// Start runs `docker compose start` on existing containers.
func (m *Manager) Start(ctx context.Context, stackID string) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil {
return err
}
m.setStatus(st, "running", "")
m.markStackContainersState(stackID, "running")
return nil
}
// Delete tears down the stack and removes the DB row. If removeVolumes is
// true, named volumes are also deleted (`compose down -v`). Destructive.
func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return err
}
if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil {
// Log but continue — DB row must not be orphaned.
slog.Warn("stack: compose down failed", "stack", st.Name, "error", err)
}
// Best-effort YAML cleanup.
_ = os.RemoveAll(filepath.Join(m.workDir, st.ID))
return m.store.DeleteStack(stackID)
}
// Services returns current service state for a stack.
func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return nil, err
}
yamlPath := ""
if st.CurrentRevisionID != "" {
if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil {
yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML)
}
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath)
}
// Logs returns the last `tail` log lines for a service (or all services if empty).
func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) {
st, err := m.store.GetStackByID(stackID)
if err != nil {
return "", err
}
if tail <= 0 {
tail = 200
}
return m.compose.Logs(ctx, st.ComposeProjectName, service, tail)
}
// --- internals ---
// syncContainerRows upserts one Container row per compose service for this
// stack so the global container index stays in sync after every deploy. The
// Docker container ID is left empty here — the reconciler resolves it from
// `docker ps` via the `com.docker.compose.project` label. Best-effort: a
// failure here is logged but does not affect deploy outcome.
func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) {
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID)
if err != nil {
slog.Warn("stack: resolve workload", "stack", st.ID, "error", err)
return
}
psCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath)
if err != nil {
slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err)
return
}
for _, svc := range services {
state := svc.State
if state == "" {
state = svc.Status
}
m.upsertStackContainer(w.ID, svc, state)
}
}
// upsertStackContainer writes a Container row for one compose service. The
// row ID is deterministic — `<workloadID>:<service>` — so re-deploys update
// the same row instead of accumulating rows.
func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) {
role := svc.Service
if role == "" {
role = svc.Name
}
if err := m.store.UpsertContainer(store.Container{
ID: workloadID + ":" + role,
WorkloadID: workloadID,
WorkloadKind: string(store.WorkloadKindStack),
Role: role,
ContainerID: "", // reconciler fills in from docker ps
Host: "local",
State: state,
LastSeenAt: store.Now(),
}); err != nil {
slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err)
}
}
// markStackContainersState bulk-updates the state of every container row for
// this stack (used by Stop/Start which don't go through compose ps).
func (m *Manager) markStackContainersState(stackID, state string) {
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID)
if err != nil {
return
}
rows, err := m.store.ListContainersByWorkload(w.ID)
if err != nil {
slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err)
return
}
for _, r := range rows {
if err := m.store.UpdateContainerState(r.ID, state); err != nil {
slog.Warn("stack: update container state", "container_row", r.ID, "error", err)
}
}
}
func (m *Manager) setStatus(st store.Stack, status, errMsg string) {
_ = m.store.UpdateStackStatus(st.ID, status, errMsg)
if m.eventBus != nil {
m.eventBus.Publish(events.Event{
Type: events.EventStackStatus,
Payload: events.StackStatusPayload{
StackID: st.ID,
Name: st.Name,
Status: status,
Error: errMsg,
},
})
}
}
func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) {
d.Status = "failed"
d.Error = errMsg
d.FinishedAt = store.Now()
_ = m.store.UpdateStackDeploy(d)
_ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID)
m.setStatus(st, "failed", errMsg)
}
// writeYAML writes yaml to <workDir>/<stackID>/rev-<n>.yml and returns the path.
func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) {
dir := filepath.Join(m.workDir, stackID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision))
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
return "", err
}
return path, nil
}
// composeProjectName sanitises a user-provided stack name into something
// `docker compose -p` will accept: lowercase, digits, dashes only.
func composeProjectName(name string) string {
name = strings.ToLower(name)
name = nonProjectChars.ReplaceAllString(name, "-")
name = strings.Trim(name, "-")
if name == "" {
name = "stack"
}
return "tinyforge-" + name
}
var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`)
func itoa(n int) string { return fmt.Sprintf("%d", n) }