Files
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

474 lines
19 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// normalizeFarmStatus maps a FarmManager status onto our lifecycle states,
// folding the legacy soft-delete value `inactive` into `archived` and treating
// anything unexpected as `active` (so an unknown state never hides a site).
func normalizeFarmStatus(s string) string {
switch s {
case "active", "readonly", "archived":
return s
case "inactive":
return "archived"
default:
return "active"
}
}
// SyncFedWikiSitesWorkflowInput is the input for the SyncFedWikiSitesWorkflow.
// It carries no workspace identity: the System tenant workspace that owns
// ownerless sites is resolved per execution by ResolveSystemWorkspaceActivity,
// never from configuration or persisted schedule args. The struct is retained as
// an empty type for backward compatibility — schedules created under the
// previous code still carry a {"DefaultWorkspaceID": "<uuid>"} Args JSON, which
// Go's default DataConverter silently ignores until the next Update rewrites it.
type SyncFedWikiSitesWorkflowInput struct{}
// SyncFedWikiSitesWorkflowOutput is the output for the SyncFedWikiSitesWorkflow.
type SyncFedWikiSitesWorkflowOutput struct {
Success bool
SitesFound int
SitesAdded int
SitesRemoved int
ErrorMessage string
SyncTimestamp time.Time
}
// SyncActivityOptions returns activity options for sync operations.
func SyncActivityOptions() workflow.ActivityOptions {
return workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: time.Minute,
MaximumAttempts: 5,
},
}
}
// SyncFedWikiSitesWorkflow synchronizes sites from the FedWiki farm to the local database.
// It fetches all sites from the FarmManager API and ensures the local database reflects
// the current state of the farm.
func SyncFedWikiSitesWorkflow(ctx workflow.Context, input SyncFedWikiSitesWorkflowInput) (*SyncFedWikiSitesWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
logger.Info("SyncFedWikiSitesWorkflow started")
var activities *Activities
activityCtx := workflow.WithActivityOptions(ctx, SyncActivityOptions())
// Step 0: Resolve the System tenant workspace by natural key at execution
// time, so the sync never trusts a workspace ID captured once at
// schedule-creation time. On failure, return the same friendly output shape
// Step 1 uses rather than a workflow-level error, keeping both pre-steps
// uniform.
var resolveResult *ResolveSystemWorkspaceOutput
if err := workflow.ExecuteActivity(activityCtx, activities.ResolveSystemWorkspaceActivity, ResolveSystemWorkspaceInput{}).Get(ctx, &resolveResult); err != nil {
logger.Error("Failed to resolve System tenant workspace", "error", err)
return &SyncFedWikiSitesWorkflowOutput{
Success: false,
ErrorMessage: "Failed to resolve the System tenant workspace. Please try again later.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
logger.Info("Resolved System tenant workspace", "workspaceID", resolveResult.WorkspaceID)
// Step 1: Fetch all sites from the FedWiki farm
var listResult *ListFedWikiSitesOutput
err := workflow.ExecuteActivity(activityCtx, activities.ListFedWikiSitesActivity, ListFedWikiSitesInput{}).Get(ctx, &listResult)
if err != nil {
logger.Error("Failed to list sites from FedWiki farm", "error", err)
return &SyncFedWikiSitesWorkflowOutput{
Success: false,
ErrorMessage: "Failed to fetch sites from FedWiki farm. Please try again later.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
logger.Info("Fetched sites from FedWiki farm", "count", len(listResult.Sites))
// Step 2: Sync the sites to the local database
var syncResult *SyncSitesToDBOutput
err = workflow.ExecuteActivity(activityCtx, activities.SyncSitesToDBActivity, SyncSitesToDBInput{
FarmSites: listResult.Sites,
DefaultWorkspaceID: resolveResult.WorkspaceID,
}).Get(ctx, &syncResult)
if err != nil {
logger.Error("Failed to sync sites to database", "error", err)
return &SyncFedWikiSitesWorkflowOutput{
Success: false,
SitesFound: len(listResult.Sites),
ErrorMessage: "Failed to sync sites to local database.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
// Step 3: Enforce "quota counts active sites" (force_reduce park / re-upgrade
// reactivate) across every workspace that owns sites. Idempotent and cheap
// when already compliant, so running it each tick covers all downgrade and
// upgrade sources without per-trigger wiring. Non-fatal: the sync itself
// already succeeded; the next tick retries.
var reconcileResult *ReconcileFedWikiQuotaOutput
if err := workflow.ExecuteActivity(activityCtx, activities.ReconcileFedWikiQuotaActivity, ReconcileFedWikiQuotaInput{}).Get(ctx, &reconcileResult); err != nil {
logger.Error("FedWiki quota reconcile failed (non-fatal)", "error", err)
} else if reconcileResult != nil && (reconcileResult.Parked > 0 || reconcileResult.Reactivated > 0) {
logger.Info("FedWiki quota reconcile applied",
"parked", reconcileResult.Parked,
"reactivated", reconcileResult.Reactivated)
}
// Step 4: Retention purge — permanently delete archived sites past the
// retention window. Non-fatal; the next tick retries.
var purgeResult *PurgeExpiredArchivedOutput
if err := workflow.ExecuteActivity(activityCtx, activities.PurgeExpiredArchivedActivity, struct{}{}).Get(ctx, &purgeResult); err != nil {
logger.Error("FedWiki retention purge failed (non-fatal)", "error", err)
} else if purgeResult != nil && purgeResult.Purged > 0 {
logger.Info("FedWiki retention purge applied", "purged", purgeResult.Purged)
}
logger.Info("SyncFedWikiSitesWorkflow completed successfully",
"sitesFound", len(listResult.Sites),
"sitesAdded", syncResult.SitesAdded,
"sitesRemoved", syncResult.SitesRemoved)
return &SyncFedWikiSitesWorkflowOutput{
Success: true,
SitesFound: len(listResult.Sites),
SitesAdded: syncResult.SitesAdded,
SitesRemoved: syncResult.SitesRemoved,
SyncTimestamp: workflow.Now(ctx),
}, nil
}
// ResolveSystemWorkspaceInput is the input for ResolveSystemWorkspaceActivity.
type ResolveSystemWorkspaceInput struct{}
// ResolveSystemWorkspaceOutput carries the resolved System tenant workspace ID.
type ResolveSystemWorkspaceOutput struct {
WorkspaceID string
}
// ResolveSystemWorkspaceActivity resolves the System tenant workspace by natural
// key (org_type='system') via systemtenant.Ensure, which is idempotent and cheap
// in steady state (one indexed SELECT). Run as Step 0 of the sync workflow so the
// workspace ID is derived fresh per execution rather than carried in workflow
// input or schedule args, where it would go stale on an app-database wipe.
func (a *Activities) ResolveSystemWorkspaceActivity(ctx context.Context, input ResolveSystemWorkspaceInput) (*ResolveSystemWorkspaceOutput, error) {
workspaceID, err := systemtenant.Ensure(ctx, a.Database)
if err != nil {
return nil, fmt.Errorf("resolve system tenant workspace: %w", err)
}
return &ResolveSystemWorkspaceOutput{WorkspaceID: workspaceID}, nil
}
// SyncSitesToDBInput is the input for the SyncSitesToDB activity.
type SyncSitesToDBInput struct {
FarmSites []SiteInfo
DefaultWorkspaceID string
}
// SyncSitesToDBOutput is the output for the SyncSitesToDB activity.
type SyncSitesToDBOutput struct {
SitesAdded int
SitesUpdated int
SitesRemoved int
}
// SyncSitesToDBActivity reconciles local site rows with the FedWiki farm. It
// PROJECTS each farm site's observed lifecycle status (active/readonly/archived)
// plus storage/last-modified onto the local row — a non-active site is retained
// with its status updated, NOT deleted. A row is deleted only when the site is
// genuinely absent from the farm, and only for the default (sync-managed)
// workspace; user-created sites are never deleted. As a usage drift backstop, a
// site whose status crossed the active boundary out-of-band (local vs farm) has
// its workspace's active-usage counter adjusted to match.
//
// Each projection also reaches the domains registry (syncPlacement): placement
// servability is re-asserted from the observed status — which is what makes an
// out-of-band archive on the farm stop /domains/ask answering for the name —
// and an operator placement is ensured for System-tenant sites sitting inside
// an operator root and inside no deeper claim. Deleting a farm-absent site
// releases its placement.
func (a *Activities) SyncSitesToDBActivity(ctx context.Context, input SyncSitesToDBInput) (*SyncSitesToDBOutput, error) {
a.Logger.Info("syncing sites to database",
slog.Int("farmSiteCount", len(input.FarmSites)),
slog.String("defaultWorkspaceID", input.DefaultWorkspaceID))
// Defensive: the System tenant workspace is ensured at boot, so this should
// never be empty. Skip rather than FK-error if it somehow is.
if input.DefaultWorkspaceID == "" {
a.Logger.Warn("system tenant workspace unresolved, skipping site sync")
return &SyncSitesToDBOutput{}, nil
}
localSites, err := a.SiteQ.ListAllSites(ctx)
if err != nil {
a.Logger.Error("failed to get local sites", slog.Any("error", err))
return nil, fmt.Errorf("failed to get local sites: %w", err)
}
localSiteMap := make(map[string]fwmod.ListAllSitesRow, len(localSites))
for _, site := range localSites {
localSiteMap[site.Domain] = site
}
farmDomainMap := make(map[string]SiteInfo, len(input.FarmSites))
for _, site := range input.FarmSites {
farmDomainMap[site.Name] = site
}
sitesAdded := 0
sitesUpdated := 0
sitesRemoved := 0
// Farm sites at names no operator root covers. The console never
// authorized those, so they get no placement and stay unservable through
// /domains/ask (a configured ask fallback covers them during migration).
// Counted and logged once per run rather than per site: on a farm carrying
// a census of foreign domains, per-site logging is noise.
sitesOutsideRoots := 0
// Project every farm site: insert new (default workspace) or update the
// observed status/storage/last-modified on an existing row.
for _, farmSite := range input.FarmSites {
status := normalizeFarmStatus(farmSite.Status)
var storage sql.NullInt64
if farmSite.StorageBytes > 0 {
storage = sql.NullInt64{Int64: farmSite.StorageBytes, Valid: true}
}
var lastMod sql.NullTime
if farmSite.LastModified != nil {
lastMod = sql.NullTime{Time: *farmSite.LastModified, Valid: true}
}
local, exists := localSiteMap[farmSite.Name]
site, err := a.SiteQ.UpsertSiteFromFarm(ctx, fwmod.UpsertSiteFromFarmParams{
WorkspaceID: input.DefaultWorkspaceID,
Domain: farmSite.Name,
Status: status,
StorageBytes: storage,
LastModifiedAt: lastMod,
})
if err != nil {
a.Logger.Error("failed to project farm site",
slog.String("domain", farmSite.Name),
slog.Any("error", err))
continue
}
// Project the name into the registry alongside the row: servability
// follows the observed status wherever the site's placement lives, and
// an operator placement is ensured only for names the console may
// authoritatively own (see syncPlacement).
if a.syncPlacement(ctx, site, input.DefaultWorkspaceID, IsServableStatus(status)) {
sitesOutsideRoots++
}
if !exists {
sitesAdded++
a.Logger.Info("added site from farm", slog.String("domain", farmSite.Name))
continue
}
sitesUpdated++
// Drift backstop: if the observed status crossed the active boundary
// relative to what we had locally, the event-path counter missed an
// out-of-band change — adjust the owning workspace's active usage.
a.reconcileUsageOnStatusDrift(ctx, local, normalizeFarmStatus(local.Status), status)
}
// Rows the farm brought in never passed the create workflow's reservation,
// so the counter is now understated. One raise-only call covers the whole
// pass: it reads the workspace's active row count, so per-insert calls
// would only repeat the same result (design D2). Best-effort — a counter
// still understated is repaired at the next boot.
if sitesAdded > 0 {
if _, err := siteusage.ReconcileWorkspace(ctx, a.EntitlementsQ, a.SiteQ, input.DefaultWorkspaceID); err != nil {
a.Logger.Warn("usage reconcile after sync failed",
slog.String("workspaceID", input.DefaultWorkspaceID),
slog.Any("error", err))
}
}
// Delete only genuinely farm-absent sites, scoped to the default workspace.
for _, localSite := range localSites {
if localSite.WorkspaceID != input.DefaultWorkspaceID {
continue // user-created site, never delete
}
if _, present := farmDomainMap[localSite.Domain]; present {
continue // present on the farm (in whatever state) — projected above
}
if err := a.SiteQ.DeleteSiteByDomain(ctx, localSite.Domain); err != nil {
a.Logger.Error("failed to delete site",
slog.String("domain", localSite.Domain),
slog.Any("error", err))
continue
}
// Deleting a sync-managed site frees its name too: the placement goes
// with the row (and an emptied carved claim with the placement).
if a.Registry != nil {
if _, err := ReleaseSiteDomain(ctx, a.Registry, localSite.SiteID); err != nil {
a.Logger.Warn("failed to release placement for farm-absent site",
slog.String("domain", localSite.Domain),
slog.Any("error", err))
}
}
sitesRemoved++
a.Logger.Info("removed farm-absent site from default workspace", slog.String("domain", localSite.Domain))
}
if sitesOutsideRoots > 0 {
a.Logger.Info("farm sites outside every operator root were projected without a placement",
slog.Int("count", sitesOutsideRoots))
}
a.Logger.Info("site sync completed",
slog.Int("sitesAdded", sitesAdded),
slog.Int("sitesUpdated", sitesUpdated),
slog.Int("sitesRemoved", sitesRemoved))
return &SyncSitesToDBOutput{
SitesAdded: sitesAdded,
SitesUpdated: sitesUpdated,
SitesRemoved: sitesRemoved,
}, nil
}
// syncPlacement projects one farm-discovered site into the domains registry,
// and reports whether the name fell outside every operator root (the caller
// counts those for a single end-of-run log line).
//
// Two distinct jobs, in one registry transaction:
//
// - Servability follows the resource. Whatever claim the site's placement
// lives in — the member's own carved claim, their external claim, or an
// operator root — an observed status crossing the archived boundary must
// flip it. Addressing the placement by resource ref makes this a no-op
// (zero rows) for a site that has none, which is exactly right.
// - Placement CREATION is operator-scoped only (design D8, and the sync
// requirement in the fedwiki-sites spec). The name must sit inside a live
// operator root and inside no deeper live claim; a name inside a member or
// external claim is the claim owner's to place, never the sync's, and a
// name outside every root was never authorized by the console at all.
//
// The row's own workspace is checked as well: only System-tenant rows get an
// operator placement here. A member's pre-registry site inside a root is the
// boot reconciliation's to adopt into a member claim — placing it under the
// operator root would silently transfer ownership of the member's name and
// then make the backfill skip it as "already placed".
func (a *Activities) syncPlacement(ctx context.Context, site fwmod.Site, systemWorkspaceID string, servable bool) (outsideRoots bool) {
if a.Registry == nil || site.SiteID == "" {
return false
}
err := a.Registry.WithLock(ctx, func(ctx context.Context, tx *domains.Tx) error {
placed, err := tx.SetServableByResource(ctx, fwmod.ModuleName, site.SiteID, servable)
if err != nil {
return err
}
if placed {
return nil // already has a placement; servability is all sync owns
}
claim, err := tx.DeepestLiveClaim(ctx, site.Domain)
if err != nil {
return err
}
switch {
case claim == nil:
outsideRoots = true
return nil
case claim.Kind != domains.KindOperatorRoot:
return nil // a member's or an external claim's name — not ours to place
case site.WorkspaceID != systemWorkspaceID:
return nil // member-owned row inside a root; boot reconciliation adopts it
}
// Never steal a name another resource already serves: a placement at
// this fqdn pointing elsewhere is a genuine collision to surface, not
// something to overwrite.
if existing, err := tx.PlacementAt(ctx, site.Domain); err != nil {
return err
} else if existing != nil {
a.Logger.Warn("farm site's name is already placed by another resource; skipping",
slog.String("domain", site.Domain),
slog.String("provider", existing.Provider),
slog.String("resourceRef", existing.ResourceRef))
return nil
}
if _, err := tx.Place(ctx, domains.PlaceParams{
WorkspaceID: systemWorkspaceID,
FQDN: site.Domain,
Provider: fwmod.ModuleName,
ResourceRef: site.SiteID,
Servable: servable,
}); err != nil {
return err
}
a.Logger.Info("placed farm site under operator root",
slog.String("domain", site.Domain),
slog.String("root", claim.RootFqdn))
return nil
})
if err != nil {
// Best-effort: the row is projected either way, and the next tick (or
// the boot reconciliation) retries. A registry failure must not abort
// the sync of the remaining sites.
a.Logger.Warn("failed to project farm site into the domains registry",
slog.String("domain", site.Domain),
slog.Any("error", err))
return false
}
return outsideRoots
}
// reconcileUsageOnStatusDrift adjusts a workspace's fedwiki_sites active-usage
// counter when a site's observed status crossed the active boundary out-of-band
// (i.e. the local status, set by our own transitions, disagreed with the farm).
// In normal operation our SetSiteStatusWorkflow keeps both in step, so this only
// fires for changes made directly on the farm. Best-effort: failures are logged.
func (a *Activities) reconcileUsageOnStatusDrift(ctx context.Context, site fwmod.ListAllSitesRow, oldStatus, newStatus string) {
const active = "active"
if oldStatus == newStatus {
return
}
entQ := entitlements.New(a.Database)
switch {
case oldStatus == active && newStatus != active:
if _, err := entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
WorkspaceID: site.WorkspaceID,
ResourceKey: "fedwiki_sites",
}); err != nil {
a.Logger.Warn("usage drift decrement failed", slog.String("domain", site.Domain), slog.Any("error", err))
}
case oldStatus != active && newStatus == active:
// Reflect farm reality; if at limit the gated increment is a no-op and we
// simply log the genuine over-limit state.
res, err := entQ.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: site.WorkspaceID,
ResourceKey: "fedwiki_sites",
})
if err != nil {
a.Logger.Warn("usage drift increment failed", slog.String("domain", site.Domain), slog.Any("error", err))
return
}
if n, _ := res.RowsAffected(); n == 0 {
a.Logger.Warn("site active on farm but workspace is at its site limit (over-limit reality)", slog.String("domain", site.Domain))
}
}
}