Three packages (api, reconciler, webhook) each carried a private 30-line
toPluginWorkload() copy that had drifted — only the api version logged
malformed public_faces JSON; the others swallowed it. Hoist the single
implementation to plugin.WorkloadFromStore() (convert.go); store is
already a plugin dependency so no new import edge or cycle forms.
Likewise the dockerfile and static sources each had a private
removeContainerByName() that disagreed (remove-all vs stop-at-first).
Docker enforces unique container names, so the two were equivalent for
every reachable state; converge on plugin.RemoveContainerByName()
(container.go, stop-at-first) with a note on why remove-all was moot.
Callers migrated; old copies removed. Adds convert_test.go pinning the
field-by-field contract and JSON edge cases.
Secrets defined once and applied to many workloads by scope (global or
per-app), encrypted at rest and resolved into container env as a
low-precedence default layer: global-shared < app-shared < image cfg.Env
< workload_env. A workload with no applicable shared secrets is
byte-identical to the prior workload_env-only behavior.
- store: shared_secrets table + CRUD + ListApplicableSharedSecrets
(enabled global + app, global-first), UNIQUE(scope,app_id,name).
- plugin.ResolveSharedSecrets + integration into BuildWorkloadEnv
(static/dockerfile) and image buildEnv; best-effort — a shared-secret
store/decrypt error never fails a deploy, and values are never logged.
- REST CRUD at /api/shared-secrets (reads authed, mutations AdminOnly);
values encrypted at the boundary via crypto.Encrypt and never returned
(only a has_value flag), mirroring workload_env. UNIQUE collisions 409.
Compose is out of scope (YAML-defined env). Frontend rule UI is Phase 2.
Reviewed: go + security APPROVE (0 CRITICAL/HIGH); two MEDIUMs fixed
(translateSQLError -> 409, no driver-message leak). Deferred defense-in-
depth: json:"-" on the model value + a description length cap.
Extract the verbatim-duplicated helpers into shared homes:
- buildEnv -> plugin.BuildWorkloadEnv (base plugin pkg; a sourceName param
preserves each plugin's slog prefix / log-scraper text)
- idShort -> plugin.IDShort
- commitStatusReporter -> staticsite.CommitStatusReporter, re-parameterized
on primitives (owner/repo/sha/targetURL/enabled) so staticsite needs no
dependency on the plugin package; reporter tests ported to staticsite
(plus a new nil-provider case)
containerNameFor/imageTagFor are intentionally left per-plugin: their
prefixes differ (dw-site- vs tf-build-) and name real Docker resources,
so merging them would risk mis-routing. Behavior-preserving; the
static/dockerfile test suites pass unchanged.
Reviewed: go APPROVE (0 CRITICAL/HIGH).
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).
Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.
Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.
Reviewed: go + typescript APPROVE; security HIGH fixed.
Report deploy status back to the Git provider as a commit status
(pending/success/failure) for git-sourced workloads (static + dockerfile).
- GitProvider.SetCommitStatus on gitea/github/gitlab over the existing
SSRF-safe client; fixed "tinyforge" context so redeploys update one row.
postJSON returns status-code-only errors (never echoes the upstream body,
which a hostile provider could use to reflect the auth token into the
best-effort log line).
- Best-effort deploy hook: pending on deploy start, success/failure on
outcome, gated on a per-workload report_commit_status flag. Never fails or
blocks a deploy; emits nothing on the unchanged-SHA short-circuit.
- UI ToggleSwitch (create + edit) + reportCommitStatus in sourceForms.ts
+ en/ru i18n.
- Tests: per-provider state mapping + request shape; reporter gating
(enabled/disabled/empty-SHA/nil/error-swallow).
Reviewed via go-reviewer + security-reviewer (0 CRITICAL/HIGH; one MEDIUM
body-echo log-leak fixed).
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