234c3c711e
Build / build (push) Successful in 10m43s
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
80 lines
2.6 KiB
Go
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
|
|
}
|