Files
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

150 lines
5.0 KiB
Go

package static
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/alexei/tinyforge/internal/staticsite/deno"
)
// prepareDenoBuild assembles the Deno container build context: api/
// becomes the routes directory, every other file lands under public/,
// and a generated router.ts + Dockerfile finishes the context.
//
// Ported verbatim from internal/staticsite/manager.go so the legacy and
// plugin-native paths produce byte-identical containers from the same
// repo content. Fall back to copy when os.Rename hits EXDEV (cross-
// device) — the build dir and context dir live under the same temp
// root in production but tests may straddle filesystems.
func prepareDenoBuild(srcDir, contextDir string) error {
apiSrc := filepath.Join(srcDir, "api")
apiDst := filepath.Join(contextDir, "api")
if err := os.Rename(apiSrc, apiDst); err != nil {
return fmt.Errorf("move api dir: %w", err)
}
publicDir := filepath.Join(contextDir, "public")
if err := os.Rename(srcDir, publicDir); err != nil {
// EXDEV (cross-device) — fall back to copy.
if err := copyDir(srcDir, publicDir); err != nil {
return fmt.Errorf("copy public dir: %w", err)
}
}
routes, err := deno.ScanRoutes(apiDst)
if err != nil {
return fmt.Errorf("scan routes: %w", err)
}
routerSrc, err := deno.GenerateRouter(routes)
if err != nil {
return fmt.Errorf("generate router: %w", err)
}
if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil {
return fmt.Errorf("write router.ts: %w", err)
}
dockerfile := deno.GenerateDockerfile()
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
}
return nil
}
// prepareStaticBuild assembles the nginx container build context: copy
// every file in srcDir into contextDir and emit the static Dockerfile.
func prepareStaticBuild(srcDir, contextDir string) error {
if err := copyDir(srcDir, contextDir); err != nil {
return fmt.Errorf("copy files: %w", err)
}
dockerfile := deno.GenerateStaticDockerfile()
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
}
return nil
}
// copyDir recursively copies a directory tree, preserving file modes.
//
// Defense in depth against attacker-controlled provider responses:
// uses Lstat (via filepath.WalkDir + d.Type()) so symlinks are
// rejected outright instead of dereferenced — a hostile repo could
// otherwise drop a symlink that copyDir would chase outside the
// build context (or through a dangling chain at build-time). Also
// rejects entries whose resolved destination escapes dst.
func copyDir(src, dst string) error {
cleanSrc := filepath.Clean(src)
cleanDst := filepath.Clean(dst)
return filepath.WalkDir(cleanSrc, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Reject anything that isn't a regular file or directory.
// In particular: symlinks, devices, sockets, named pipes —
// none of which belong in a static-site build context.
if !d.IsDir() && !d.Type().IsRegular() {
return fmt.Errorf("refusing to copy non-regular entry %s (mode %s)", path, d.Type())
}
relPath, err := filepath.Rel(cleanSrc, path)
if err != nil {
return err
}
dstPath := filepath.Join(cleanDst, relPath)
// Belt-and-braces: filepath.Rel + Join shouldn't ever produce
// an escaping path, but if a future refactor introduces one
// (e.g. allowing non-cleaned roots), surface it loudly here
// rather than silently writing outside the build context.
if !strings.HasPrefix(dstPath, cleanDst+string(os.PathSeparator)) && dstPath != cleanDst {
return fmt.Errorf("refusing to write outside build context: %s", dstPath)
}
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dstPath, data, info.Mode())
})
}
// verifyDownloadInsideRoot walks root and rejects any entry that has
// resolved to a symlink or whose resolved path escapes root. Used as a
// post-download guard against attacker-controlled tree responses from
// the Git provider — even though the providers themselves should
// never write outside their destination, this is the second line of
// defense and runs before the build context copy so a malicious
// download is contained.
func verifyDownloadInsideRoot(root string) error {
cleanRoot := filepath.Clean(root)
return filepath.WalkDir(cleanRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !d.Type().IsRegular() {
return fmt.Errorf("downloaded tree contains non-regular entry %s (mode %s)",
path, d.Type())
}
clean := filepath.Clean(path)
if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) {
return fmt.Errorf("downloaded entry escapes root: %s", clean)
}
return nil
})
}