Files
tiny-forge/internal/workload/plugin/source/static/naming.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

80 lines
2.6 KiB
Go

package static
import (
"fmt"
"strings"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// idShort returns the first 8 chars of a workload ID, used as the
// uniqueness suffix on every Docker resource (container, image,
// volume) the static source materializes. Workload names are not
// UNIQUE in the schema today; including the ID short prevents two
// workloads with the same name from clobbering each other's
// container, image, or storage volume.
func idShort(w plugin.Workload) string {
if len(w.ID) < 8 {
return w.ID
}
return w.ID[:8]
}
// containerNameFor is the deterministic container name. Includes
// w.Name for visual continuity in `docker ps` plus the ID short for
// uniqueness.
func containerNameFor(w plugin.Workload) string {
return fmt.Sprintf("dw-site-%s-%s", w.Name, idShort(w))
}
// imageTagFor is the deterministic image tag — same shape as the
// container name so the linkage between an image and the workload
// that owns it stays obvious from `docker images`.
func imageTagFor(w plugin.Workload) string {
return fmt.Sprintf("dw-site-%s-%s:latest", w.Name, idShort(w))
}
// siteVolumeKey is the input to docker.SiteVolumeName / EnsureSiteVolume
// / RemoveSiteVolume. Composing it here (instead of building the full
// name ourselves) keeps the naming concern in one place — those docker
// helpers wrap the value with their own `tinyforge-site-...-data`
// envelope. Including idShort prevents two workloads sharing a name
// from sharing one persistent volume.
func siteVolumeKey(w plugin.Workload) string {
return fmt.Sprintf("%s-%s", w.Name, idShort(w))
}
// sanitizeError clamps an error string so persisting it (in
// containers.extra_json's last_error) or echoing it (via the
// outbound notification webhook) cannot leak a multi-line response
// body, an HTTP header echoing the access token, or a stack trace.
//
// Strategy:
// - Reduce to a single line (replace any newline / tab with space).
// - Cap to a short maxLen so a very long Gitea/GitHub error body
// never round-trips into operator-visible state.
// - Redact the access token verbatim if it appears in the message
// (defense in depth — providers shouldn't echo tokens but a
// misbehaving one could).
func sanitizeError(msg, accessToken string) string {
if msg == "" {
return ""
}
if accessToken != "" {
msg = strings.ReplaceAll(msg, accessToken, "[REDACTED]")
}
// Collapse whitespace runs onto one line.
msg = strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', '\t':
return ' '
}
return r
}, msg)
const maxLen = 240
if len(msg) > maxLen {
msg = msg[:maxLen] + "…"
}
return msg
}