Files
tiny-forge/internal/workload/plugin/source/static/reconcile.go
T
alexei.dolgolyov 234c3c711e
Build / build (push) Successful in 10m43s
feat(static): inline static-source plugin; drop phantom-row adapter
Lift the static-site deploy pipeline from internal/staticsite/manager.go
into internal/workload/plugin/source/static/ so plugin-native static
workloads operate directly on plugin.Workload + the containers table +
workload_env. The cmd/server/static_backend.go phantom-row adapter is
gone; the legacy static_sites table is no longer touched on plugin
deploys.

Backend
- new state.go: runtimeState (last_commit_sha, last_sync_at,
  last_error, status) persisted in containers.extra_json under the
  deterministic row id <workloadID>:site
- per-workload sync.Mutex serializes saveState read-modify-write so
  parallel deploys for the same workload can't race container_id /
  proxy_route_id writes
- extra_json round-trips through map[string]json.RawMessage so
  unknown keys survive — typed runtimeStateKeys are stripped before
  merge so clearing a typed field actually drops the key
- new env.go reads workload_env (replaces static_site_secrets for
  plugin-native sites); decrypt-failure logs and skips one entry
  rather than failing the whole deploy
- new build.go ports prepareDenoBuild + prepareStaticBuild + copyDir;
  copyDir uses filepath.WalkDir + Lstat to refuse symlinks and
  non-regular files
- new deploy.go is the ~300-line core; intent.Reason gates force vs
  skip-if-no-changes; success-path saveState failure rolls back
  container + proxy route and writes "failed" state (no orphans)
- new teardown.go combines Remove + Stop; idempotent on
  never-deployed workloads
- new reconcile.go refreshes container state from Docker; flips
  runtimeState.Status to failed when the container is missing/crashed

Hardening (from go-reviewer + security-reviewer subagent passes;
1 CRITICAL + 5 HIGH + 3 MEDIUM addressed before merge)
- path-traversal defense in all 3 providers (gitea_content,
  github_provider, gitlab_provider): reject tree entries whose
  resolved local path escapes destDir
- verifyDownloadInsideRoot walks the build dir post-download as a
  second line of defense
- sanitizeError redacts the access token, collapses to one line, and
  clamps to 240 bytes before persisting to extra_json or fanning out
  to the notification webhook
- container/image/volume names suffixed with workload-id short prefix
  (workload name is not UNIQUE in schema)
- primaryDomain reads settings.Domain to complete a bare subdomain
  face into a full FQDN (matches legacy Manager behavior)
- ctx-aware health-check sleep
- json.Marshal for event metadata (was fmt.Sprintf JSON template)
- strings.HasPrefix for failed-status detection (was brittle slice
  expression)

Wire-up
- cmd/server/main.go: removed wireStaticBackend(...) call; existing
  blank import on _ ".../source/static" drives init() registration
- cmd/server/static_backend.go deleted

Doc
- WORKLOAD_REFACTOR_TODO: static port marked DONE; next focus is
  the hard legacy cutover (drop /api/projects, /api/stacks,
  /api/sites, /api/stages + their tables, internal/stack +
  internal/staticsite packages, frontend /projects /stacks /sites)

Behavior notes for operators
- plugin-native static workloads no longer write to static_sites;
  legacy /api/sites/* still serves original rows unchanged
- legacy tinyforge.static-site / .static-site-name container labels
  dropped on plugin deploys; canonical tinyforge.workload.id / .kind
  cover ownership
- container/image/volume names gained an 8-char ID suffix
  (e.g. dw-site-mysite-a1b2c3d4); legacy-deployed sites keep the
  old shape until redeployed through the plugin path
2026-05-16 02:56:23 +03:00

72 lines
2.4 KiB
Go

package static
import (
"context"
"log/slog"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// reconcile syncs the container row's state column with Docker reality
// for this workload's single container, and marks the runtime state as
// "failed" if the container is gone or has crashed since the last
// deploy. Intentionally minimal — the legacy HealthChecker still
// services rows in the static_sites table, so we don't need to mirror
// its full behavior here. Future versions can re-deploy on a missing
// container; today we just keep the index honest.
func reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
st, prevContainer, err := loadState(deps, w)
if err != nil {
return err
}
if prevContainer == nil || prevContainer.ContainerID == "" {
return nil
}
running, err := deps.Docker.IsContainerRunning(ctx, prevContainer.ContainerID)
if err != nil {
// Most likely "no such container" — mark the row missing so
// the UI surfaces it; the runtime state's status moves to
// "failed" so the dashboard does not falsely report deployed.
if uerr := deps.Store.UpdateContainerState(prevContainer.ID, "missing"); uerr != nil {
slog.Warn("static source: mark missing", "site", w.Name, "error", uerr)
}
if st.Status == "deployed" {
if uerr := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
rs.Status = "failed"
rs.LastError = "container not found"
c.State = "missing"
}); uerr != nil {
slog.Warn("static source: persist missing-state", "site", w.Name, "error", uerr)
}
publishEvent(deps, w, "failed: container not found")
}
return nil
}
desired := "running"
if !running {
desired = "stopped"
}
if prevContainer.State != desired {
if err := deps.Store.UpdateContainerState(prevContainer.ID, desired); err != nil {
slog.Warn("static source: state sync", "site", w.Name, "error", err)
}
}
// Keep runtime status honest: a deployed-then-crashed container
// should report failed so the dashboard / event triggers fire.
if !running && st.Status == "deployed" {
if err := saveState(deps, w, func(rs *runtimeState, c *store.Container) {
rs.Status = "failed"
rs.LastError = "container stopped unexpectedly"
c.State = "stopped"
}); err != nil {
slog.Warn("static source: persist crashed-state", "site", w.Name, "error", err)
}
publishEvent(deps, w, "failed: container stopped unexpectedly")
}
return nil
}