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
End-to-end extraction of the Instance concept. After this commit:
* internal/store/instances.go — DELETED
* internal/store/models.go — Instance struct gone, ProxyRoute moved here
* containers table is the single source of truth for project/stack/site
container state. instances table is dropped via DROP TABLE migration
(idempotent; re-runnable on every boot).
* Legacy tinyforge.project / tinyforge.stage / tinyforge.instance-id
Docker labels are no longer emitted; only tinyforge.workload.{id,kind},
tinyforge.role, and tinyforge.managed are stamped on new containers.
Backend rewrites:
- internal/deployer: executeDeploy + blueGreenDeploy + rollback +
promote use store.Container natively. New
removeContainer() replaces removeInstance().
enforceMaxInstances reads via
ListContainersByStageID.
- internal/reconciler: legacy tinyforge.instance-id dispatch removed;
upsertByWorkloadLabel now finds existing rows
by docker container ID first and falls back to
the deterministic workloadID:role key.
- internal/stale/scanner: Scan + new FindStaleContainers walk the
containers table; emit StaleContainer JSON.
- internal/stats/collector: ListContainers replaces ListAllInstances.
- internal/webhook/handler: workload-secret lookup tried first; falls back
to project / static_site secret column.
- internal/api: instances.go, stale.go, stats.go, stats_history.go,
projects.go, settings.go, docker.go, dns.go all read /
write through Container.
Docker layer:
- ManagedContainer exposes WorkloadID/Kind/Role from the canonical labels.
- ListContainers filters by tinyforge.managed=true.
- Network creation uses LabelManaged instead of LabelProject.
Frontend:
- Instance type is now a Container alias; .status → .state,
.last_alive_at → .last_seen_at.
- InstanceCard takes stageId as a prop (no longer derived from Instance).
- StaleContainer JSON shape rewritten: { container, workload_name, role,
days_stale }. StaleContainerCard + /containers/stale page updated.
- ProjectCard / homepage / SystemHealthCard filter by .state.
The migration loop now tolerates "no such table" alongside "duplicate
column" / "already exists" so obsolete ALTER TABLE entries targeting the
dropped instances table no-op cleanly on first boot.
Tests: store + deployer + reconciler + webhook + staticsite + notify all
still pass. Frontend svelte-check: zero errors.
Stack manager now upserts a Container row per compose service
after every deploy (deterministic ID = workloadID + service so
re-deploys update in place). Stop/Start bulk-flip the state
field. Compose containers don't yet carry the new tinyforge.*
labels — the reconciler will join via com.docker.compose.project
when it lands.
Static site manager passes WorkloadID/Kind to ContainerConfig
so the new labels are stamped, and upserts a single Container
row per site (deterministic ID = workloadID + ":site"). Stop/
Start flip state. Delete cascades through the store layer.
Now every Tinyforge-managed container — project, stack service,
or static site — has a row in the containers index, ready for
the reconciler + global view in the next batches.
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
- Add storage_enabled and storage_limit_mb columns to static_sites.
- Create/attach Docker volumes (tinyforge-site-{name}-data) for Deno
sites with storage enabled, mounted at /app/data.
- Grant --allow-write=/app/data in Deno container CMD.
- Add storage usage API endpoint (GET /api/sites/{id}/storage).
- Show storage section in site detail page with usage bar.
- Add storage toggle and limit field to new site wizard.
- Use ConfirmDialog for secret deletion instead of inline delete.
Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.
Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.