From b6f20599d7d8d5f659307de3b53308237673368d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 13:41:03 +0300 Subject: [PATCH] feat(workload): wire stack + static-site into containers index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/stack/manager.go | 71 ++++++++++++++++++++++++++++++++++ internal/staticsite/manager.go | 62 ++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/internal/stack/manager.go b/internal/stack/manager.go index 7affa72..dd70cd1 100644 --- a/internal/stack/manager.go +++ b/internal/stack/manager.go @@ -128,6 +128,7 @@ func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error _ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID) _ = m.store.SetStackCurrentRevision(st.ID, rev.ID) m.setStatus(st, "running", "") + m.syncContainerRows(ctx, st, yamlPath) return nil } @@ -217,6 +218,7 @@ func (m *Manager) Stop(ctx context.Context, stackID string) error { return err } m.setStatus(st, "stopped", "") + m.markStackContainersState(stackID, "stopped") return nil } @@ -230,6 +232,7 @@ func (m *Manager) Start(ctx context.Context, stackID string) error { return err } m.setStatus(st, "running", "") + m.markStackContainersState(stackID, "running") return nil } @@ -280,6 +283,74 @@ func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) ( // --- internals --- +// syncContainerRows upserts one Container row per compose service for this +// stack so the global container index stays in sync after every deploy. The +// Docker container ID is left empty here — the reconciler resolves it from +// `docker ps` via the `com.docker.compose.project` label. Best-effort: a +// failure here is logged but does not affect deploy outcome. +func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) { + w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID) + if err != nil { + slog.Warn("stack: resolve workload", "stack", st.ID, "error", err) + return + } + psCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath) + if err != nil { + slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err) + return + } + for _, svc := range services { + state := svc.State + if state == "" { + state = svc.Status + } + m.upsertStackContainer(w.ID, svc, state) + } +} + +// upsertStackContainer writes a Container row for one compose service. The +// row ID is deterministic — `:` — so re-deploys update +// the same row instead of accumulating rows. +func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) { + role := svc.Service + if role == "" { + role = svc.Name + } + if err := m.store.UpsertContainer(store.Container{ + ID: workloadID + ":" + role, + WorkloadID: workloadID, + WorkloadKind: string(store.WorkloadKindStack), + Role: role, + ContainerID: "", // reconciler fills in from docker ps + Host: "local", + State: state, + LastSeenAt: store.Now(), + }); err != nil { + slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err) + } +} + +// markStackContainersState bulk-updates the state of every container row for +// this stack (used by Stop/Start which don't go through compose ps). +func (m *Manager) markStackContainersState(stackID, state string) { + w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID) + if err != nil { + return + } + rows, err := m.store.ListContainersByWorkload(w.ID) + if err != nil { + slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err) + return + } + for _, r := range rows { + if err := m.store.UpdateContainerState(r.ID, state); err != nil { + slog.Warn("stack: update container state", "container_row", r.ID, "error", err) + } + } +} + func (m *Manager) setStatus(st store.Stack, status, errMsg string) { _ = m.store.UpdateStackStatus(st.ID, status, errMsg) if m.eventBus != nil { diff --git a/internal/staticsite/manager.go b/internal/staticsite/manager.go index 62782e7..eb3fe18 100644 --- a/internal/staticsite/manager.go +++ b/internal/staticsite/manager.go @@ -55,6 +55,56 @@ func (m *Manager) SetProxyProvider(provider proxy.Provider) { m.proxyProvider = provider } +// resolveSiteWorkloadID returns the workload ID paired with a static site. +// Boot-time backfill guarantees the row exists; on lookup miss this returns +// empty so the caller can decide (the deployer continues without the label). +func (m *Manager) resolveSiteWorkloadID(siteID string) string { + w, err := m.store.GetWorkloadByRef(store.WorkloadKindSite, siteID) + if err != nil { + slog.Warn("static site: resolve workload", "site_id", siteID, "error", err) + return "" + } + return w.ID +} + +// upsertSiteContainer keeps the global container index in sync with the +// site's current container. Row ID is deterministic (workloadID + ":site") +// so re-deploys update in place. Best-effort. +func (m *Manager) upsertSiteContainer(site store.StaticSite, containerID, state string) { + workloadID := m.resolveSiteWorkloadID(site.ID) + if workloadID == "" { + return + } + if err := m.store.UpsertContainer(store.Container{ + ID: workloadID + ":site", + WorkloadID: workloadID, + WorkloadKind: string(store.WorkloadKindSite), + Role: "", + ContainerID: containerID, + Host: "local", + State: state, + Subdomain: site.Domain, + ProxyRouteID: site.ProxyRouteID, + LastSeenAt: store.Now(), + }); err != nil { + slog.Warn("static site: upsert container row", "site_id", site.ID, "error", err) + } +} + +// markSiteContainerState bulk-updates state for the site's container row. +// Used by Stop/Start which only flip state. +func (m *Manager) markSiteContainerState(siteID, state string) { + workloadID := m.resolveSiteWorkloadID(siteID) + if workloadID == "" { + return + } + rowID := workloadID + ":site" + if err := m.store.UpdateContainerState(rowID, state); err != nil { + // NotFound is fine — the site may have never deployed. + slog.Debug("static site: update container state", "row", rowID, "error", err) + } +} + // Deploy fetches content from Gitea and deploys a static site container. // If force is true, skips the "no changes" check and always rebuilds/redeploys. func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { @@ -233,8 +283,11 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { "tinyforge.static-site": site.ID, "tinyforge.static-site-name": site.Name, }, - Project: "static-site", - Stage: site.Name, + Project: "static-site", + Stage: site.Name, + WorkloadID: m.resolveSiteWorkloadID(site.ID), + WorkloadKind: string(store.WorkloadKindSite), + Role: "", }) if err != nil { // Container might already exist — try to remove and recreate. @@ -341,6 +394,9 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil { slog.Error("static site: failed to update container info", "site", site.Name, "error", err) } + site.ContainerID = containerID + site.ProxyRouteID = proxyRouteID + m.upsertSiteContainer(site, containerID, "running") m.updateStatus(site.ID, "deployed", latestSHA, "") m.publishEvent(site.ID, site.Name, "deployed") @@ -406,6 +462,7 @@ func (m *Manager) Stop(ctx context.Context, siteID string) error { if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil { slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err) } + m.markSiteContainerState(site.ID, "stopped") m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "") m.publishEvent(site.ID, site.Name, "stopped") @@ -468,6 +525,7 @@ func (m *Manager) Start(ctx context.Context, siteID string) error { } } + m.markSiteContainerState(site.ID, "running") m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "") m.publishEvent(site.ID, site.Name, "deployed")