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."
623 lines
24 KiB
Go
623 lines
24 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"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"
|
|
"go.temporal.io/sdk/temporal"
|
|
)
|
|
|
|
// Activities holds dependencies for FedWiki domain activities.
|
|
type Activities struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
Database *sql.DB
|
|
// Registry is the domains registry: the authority for every name FedWiki
|
|
// serves. Allocation, release, and servability writes go through it
|
|
// (design D8). Nil in tests that exercise no name-bearing path; every
|
|
// registry touch below is nil-guarded so those keep working.
|
|
Registry *domains.Registry
|
|
Logger *slog.Logger
|
|
Client *FarmManagerClient
|
|
FedWikiAllowedDomains []string // Domains where users can create sites
|
|
SupportURL string
|
|
}
|
|
|
|
// ActivitiesConfig holds configuration for creating FedWiki activities.
|
|
type ActivitiesConfig struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
Database *sql.DB
|
|
Registry *domains.Registry
|
|
Logger *slog.Logger
|
|
FedWikiFarmAPIURL string // URL for FarmManager API calls
|
|
FedWikiAllowedDomains []string // Domains where users can create sites
|
|
FedWikiAdminToken string
|
|
SupportURL string
|
|
}
|
|
|
|
// NewActivities creates a new Activities instance with the FarmManager client.
|
|
func NewActivities(cfg ActivitiesConfig) *Activities {
|
|
client := NewFarmManagerClient(cfg.FedWikiFarmAPIURL, cfg.FedWikiAdminToken)
|
|
if cfg.Logger != nil {
|
|
cfg.Logger.Info("FedWiki activities initialized",
|
|
slog.String("farmAPIURL", cfg.FedWikiFarmAPIURL),
|
|
slog.Any("allowedDomains", cfg.FedWikiAllowedDomains),
|
|
slog.Bool("hasToken", cfg.FedWikiAdminToken != ""))
|
|
}
|
|
return &Activities{
|
|
SiteQ: cfg.SiteQ,
|
|
EntitlementsQ: cfg.EntitlementsQ,
|
|
Database: cfg.Database,
|
|
Registry: cfg.Registry,
|
|
Logger: cfg.Logger,
|
|
Client: client,
|
|
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
|
|
SupportURL: cfg.SupportURL,
|
|
}
|
|
}
|
|
|
|
// resolveFullDomain builds the site's full DNS name from a create request,
|
|
// falling back to the first configured farm domain for a hosted site with no
|
|
// explicit parent. Shared by AllocateDomainActivity and
|
|
// CreateFedWikiSiteActivity so the reserved placement's fqdn and the
|
|
// `fedwiki.sites` row's domain can never disagree about the name.
|
|
func (a *Activities) resolveFullDomain(domain, siteDomain string, isCustomDomain bool) (string, error) {
|
|
if siteDomain == "" && !isCustomDomain {
|
|
if len(a.FedWikiAllowedDomains) == 0 {
|
|
return "", temporal.NewNonRetryableApplicationError(
|
|
"No allowed domains configured for site creation",
|
|
"ConfigurationError",
|
|
nil,
|
|
)
|
|
}
|
|
siteDomain = a.FedWikiAllowedDomains[0]
|
|
}
|
|
return BuildFullDomain(domain, siteDomain, isCustomDomain), nil
|
|
}
|
|
|
|
// AllocateDomainInput is the input for the AllocateDomain activity. It mirrors
|
|
// the create request's name fields rather than a pre-built FQDN so the
|
|
// allowed-domains fallback stays in one place (resolveFullDomain).
|
|
type AllocateDomainInput struct {
|
|
// SiteID is the id to reserve the name against. It comes from the
|
|
// workflow, not from this activity, because the activity is at-least-once:
|
|
// an id minted here would differ between attempts, and a retry after a
|
|
// commit whose result was lost would collide with its own first placement.
|
|
SiteID string
|
|
WorkspaceID string
|
|
Domain string // The subdomain part (e.g., "mysite"), or the full name for a custom domain
|
|
SiteDomain string // The parent domain (e.g., "localtest.me") - ignored for custom domains
|
|
IsCustomDomain bool
|
|
}
|
|
|
|
// AllocateDomainOutput carries the reservation: the site id the placement is
|
|
// bound to, and the full domain it was reserved at.
|
|
type AllocateDomainOutput struct {
|
|
SiteID string
|
|
Domain string
|
|
}
|
|
|
|
// AllocateDomainActivity reserves the site's name in the domains registry
|
|
// before any farm call (design D8): one transaction ensures the claim
|
|
// (auto-carving a member claim for a hosted label) and inserts the placement
|
|
// against the workflow's site id, so the name is authoritatively held across
|
|
// the farm retry window. Namespace refusals are permanent — a name someone
|
|
// else holds will not free itself mid-retry — so they surface as
|
|
// non-retryable, with the collapsed member-facing message (never the internal
|
|
// verdict, which would make the console an oracle for who holds a name).
|
|
//
|
|
// Re-running it against a reservation it already made is a no-op that returns
|
|
// the same output, so a lost result costs nothing but a second attempt.
|
|
func (a *Activities) AllocateDomainActivity(ctx context.Context, input AllocateDomainInput) (*AllocateDomainOutput, error) {
|
|
fullDomain, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Registry == nil {
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
"Domain allocation is not available",
|
|
"ConfigurationError",
|
|
nil,
|
|
)
|
|
}
|
|
if input.SiteID == "" {
|
|
// Retry-safety depends on the id being the workflow's; minting a
|
|
// fallback here would reintroduce exactly the collision it prevents.
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
"Domain allocation is not available",
|
|
"ConfigurationError",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
siteID := input.SiteID
|
|
if err := AllocateSiteDomain(ctx, a.Registry, input.WorkspaceID, fullDomain, siteID); err != nil {
|
|
a.Logger.Warn("failed to allocate site domain",
|
|
slog.String("workspaceID", input.WorkspaceID),
|
|
slog.String("domain", fullDomain),
|
|
slog.Any("error", err))
|
|
if msg := allocationRefusal(err); msg != "" {
|
|
return nil, temporal.NewNonRetryableApplicationError(msg, "InvalidDomain", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to allocate site domain %s: %w", fullDomain, err)
|
|
}
|
|
|
|
a.Logger.Info("site domain allocated",
|
|
slog.String("siteID", siteID),
|
|
slog.String("domain", fullDomain))
|
|
return &AllocateDomainOutput{SiteID: siteID, Domain: fullDomain}, nil
|
|
}
|
|
|
|
// allocationRefusal maps a registry error onto the member-facing message for a
|
|
// permanent refusal, or "" when the failure was transient (a database blip,
|
|
// say) and retrying is right. ErrNameUnavailable collapses to the registry's
|
|
// own no-reason message; the structural refusals get their own copy because
|
|
// they describe what the member must do next, not who holds what.
|
|
func allocationRefusal(err error) string {
|
|
var unavailable *domains.Unavailable
|
|
switch {
|
|
case errors.As(err, &unavailable):
|
|
return unavailable.MemberMessage()
|
|
case errors.Is(err, domains.ErrInvalidName):
|
|
return "That domain name is not valid. Please check it and try again."
|
|
case errors.Is(err, domains.ErrNameUnavailable):
|
|
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
|
|
case errors.Is(err, domains.ErrNoLiveClaim), errors.Is(err, domains.ErrClaimRequired):
|
|
return "You need to verify that you control this domain before creating a site on it."
|
|
case errors.Is(err, domains.ErrForeignClaim), errors.Is(err, domains.ErrClaimNotActive):
|
|
return domains.Availability{Verdict: domains.VerdictTaken}.MemberMessage()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ReleaseDomainInput is the input for the ReleaseDomain activity: the site id
|
|
// the placement was reserved against, which is the registry's resource ref.
|
|
type ReleaseDomainInput struct {
|
|
SiteID string
|
|
}
|
|
|
|
// ReleaseDomainActivity frees a reserved name — the saga's compensation for a
|
|
// farm-side create failure. Idempotent (an already-released placement is not
|
|
// an error), so Temporal's retries and the boot reconciliation backstop can
|
|
// both run over the same reservation without conflict.
|
|
func (a *Activities) ReleaseDomainActivity(ctx context.Context, input ReleaseDomainInput) error {
|
|
if a.Registry == nil || input.SiteID == "" {
|
|
return nil
|
|
}
|
|
result, err := ReleaseSiteDomain(ctx, a.Registry, input.SiteID)
|
|
if err != nil {
|
|
return fmt.Errorf("release site domain for %s: %w", input.SiteID, err)
|
|
}
|
|
a.Logger.Info("site domain released",
|
|
slog.String("siteID", input.SiteID),
|
|
slog.Bool("placementFound", result.Found),
|
|
slog.Bool("claimReleased", result.ClaimReleased))
|
|
return nil
|
|
}
|
|
|
|
// CreateFedWikiSiteInput is the input for the CreateFedWikiSite activity.
|
|
type CreateFedWikiSiteInput struct {
|
|
// SiteID is the id AllocateDomainActivity already reserved the name
|
|
// against; the `fedwiki.sites` row must be inserted with it so the
|
|
// placement's resource_ref resolves (design D8). Never blank on the saga
|
|
// path — the workflow allocates before it calls this.
|
|
SiteID string
|
|
WorkspaceID string
|
|
Domain string // The subdomain part (e.g., "mysite")
|
|
SiteDomain string // The parent domain (e.g., "localtest.me") - ignored for custom domains
|
|
OwnerName string // Display name for the owner
|
|
OwnerID string // OIDC subject (OAuth2 ID) for owner verification
|
|
IsCustomDomain bool
|
|
}
|
|
|
|
// CreateFedWikiSiteOutput is the output for the CreateFedWikiSite activity.
|
|
type CreateFedWikiSiteOutput struct {
|
|
SiteID string
|
|
Domain string
|
|
}
|
|
|
|
// CreateFedWikiSiteActivity creates a new site on the FedWiki farm and records it in the database.
|
|
func (a *Activities) CreateFedWikiSiteActivity(ctx context.Context, input CreateFedWikiSiteInput) (*CreateFedWikiSiteOutput, error) {
|
|
if input.SiteID == "" {
|
|
// The registry reserved the name against a specific id before this
|
|
// activity ran; inserting a different one would orphan the placement.
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
"Site creation was not allocated a domain reservation",
|
|
"InvalidDomain",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// Build the full domain for FarmManager API and storage — the same
|
|
// resolution AllocateDomainActivity used, so the row shadows the placement.
|
|
fullDomain, err := a.resolveFullDomain(input.Domain, input.SiteDomain, input.IsCustomDomain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a.Logger.Info("creating FedWiki site",
|
|
slog.String("workspaceID", input.WorkspaceID),
|
|
slog.String("domain", fullDomain),
|
|
slog.String("ownerName", input.OwnerName),
|
|
slog.String("ownerID", input.OwnerID))
|
|
|
|
// Call the FarmManager API to create the site
|
|
if _, err := a.Client.CreateSite(ctx, fullDomain, input.OwnerName, input.OwnerID); err != nil {
|
|
a.Logger.Error("failed to create site via FarmManager API",
|
|
slog.String("domain", fullDomain),
|
|
slog.Any("error", err))
|
|
|
|
// Check if this is a non-retryable error (e.g., 409 Conflict)
|
|
var apiErr *FarmManagerAPIError
|
|
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
apiErr.UserMessage(),
|
|
"FarmManagerAPIError",
|
|
err,
|
|
)
|
|
}
|
|
return nil, fmt.Errorf("failed to create site on FedWiki farm: %w", err)
|
|
}
|
|
|
|
// Record the site in our database with the full domain, under the id
|
|
// AllocateDomainActivity already reserved the name against: site_id is no
|
|
// longer defaulted by Postgres, because the placement that holds the name
|
|
// points at this id and was written before the row existed (design D8).
|
|
site, err := a.SiteQ.CreateSite(ctx, fwmod.CreateSiteParams{
|
|
SiteID: input.SiteID,
|
|
WorkspaceID: input.WorkspaceID,
|
|
Domain: fullDomain,
|
|
})
|
|
if err != nil {
|
|
a.Logger.Error("failed to record site in database",
|
|
slog.String("domain", fullDomain),
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("site created on FedWiki but failed to record locally: %w", err)
|
|
}
|
|
|
|
a.Logger.Info("FedWiki site created successfully",
|
|
slog.String("siteID", site.SiteID),
|
|
slog.String("domain", fullDomain))
|
|
|
|
return &CreateFedWikiSiteOutput{
|
|
SiteID: site.SiteID,
|
|
Domain: site.Domain,
|
|
}, nil
|
|
}
|
|
|
|
// DeleteFedWikiSiteInput is the input for the DeleteFedWikiSite activity.
|
|
type DeleteFedWikiSiteInput struct {
|
|
Domain string // Full domain as stored in database
|
|
}
|
|
|
|
// DeleteFedWikiSiteOutput is the output for the DeleteFedWikiSite activity.
|
|
type DeleteFedWikiSiteOutput struct {
|
|
Success bool
|
|
}
|
|
|
|
// DeleteFedWikiSiteActivity deletes a site from the FedWiki farm and removes it from the database.
|
|
func (a *Activities) DeleteFedWikiSiteActivity(ctx context.Context, input DeleteFedWikiSiteInput) (*DeleteFedWikiSiteOutput, error) {
|
|
a.Logger.Info("deleting FedWiki site",
|
|
slog.String("domain", input.Domain))
|
|
|
|
// Read the site id BEFORE the row goes away: the registry addresses
|
|
// placements by resource ref, and after DeleteSiteByDomain there is
|
|
// nothing left to derive it from. A missing row is not fatal — this
|
|
// activity is at-least-once, so a retry that finds no row is one whose
|
|
// earlier attempt already deleted it, and the release below already ran.
|
|
var siteID string
|
|
if site, err := a.SiteQ.GetSiteByDomain(ctx, input.Domain); err == nil {
|
|
siteID = site.SiteID
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
a.Logger.Warn("failed to read site before delete",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
}
|
|
|
|
// Call the FarmManager API to delete the site (hard delete)
|
|
_, err := a.Client.HardDeleteSite(ctx, input.Domain)
|
|
if err != nil {
|
|
a.Logger.Error("failed to delete site via FarmManager API",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
|
|
// Check if this is a non-retryable error (e.g., 403 Forbidden, 401 Unauthorized)
|
|
var apiErr *FarmManagerAPIError
|
|
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
apiErr.UserMessage(),
|
|
"FarmManagerAPIError",
|
|
err,
|
|
)
|
|
}
|
|
return nil, fmt.Errorf("failed to delete site on FedWiki farm: %w", err)
|
|
}
|
|
|
|
// Free the name BEFORE the row that names it: a permanent delete releases
|
|
// the placement and, when this was the carved claim's last occupant, the
|
|
// claim with it — so the label becomes available again, matching
|
|
// delete-frees-the-domain.
|
|
//
|
|
// Order is load-bearing. Deleting the row first and releasing after would
|
|
// leave a retry (the release failed, the activity re-runs) with no row to
|
|
// read the site id from, so it would skip the release and strand the
|
|
// placement forever. Releasing first makes every prefix of this activity
|
|
// recoverable: the release is idempotent, and a row still present with its
|
|
// name already freed is re-adopted by boot reconciliation.
|
|
if a.Registry != nil && siteID != "" {
|
|
if err := a.ReleaseDomainActivity(ctx, ReleaseDomainInput{SiteID: siteID}); err != nil {
|
|
a.Logger.Error("failed to release site placement",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("site deleted on FedWiki but failed to release its name: %w", err)
|
|
}
|
|
}
|
|
|
|
// Remove the site from our database
|
|
err = a.SiteQ.DeleteSiteByDomain(ctx, input.Domain)
|
|
if err != nil {
|
|
a.Logger.Error("failed to remove site from database",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("site deleted on FedWiki but failed to remove locally: %w", err)
|
|
}
|
|
|
|
a.Logger.Info("FedWiki site deleted successfully",
|
|
slog.String("domain", input.Domain))
|
|
|
|
return &DeleteFedWikiSiteOutput{Success: true}, nil
|
|
}
|
|
|
|
// SetSiteStatusInput is the input for the SetSiteStatus activity.
|
|
type SetSiteStatusInput struct {
|
|
Domain string
|
|
Status string // active | readonly | archived
|
|
}
|
|
|
|
// SetSiteStatusOutput is the output for the SetSiteStatus activity.
|
|
type SetSiteStatusOutput struct {
|
|
Success bool
|
|
}
|
|
|
|
// SetSiteStatusActivity changes a site's lifecycle status on the FedWiki farm
|
|
// (PATCH) and projects the new status onto the local row. The active-usage
|
|
// adjustment is handled by the orchestrating workflow (saga), mirroring how
|
|
// create/delete reserve and compensate usage.
|
|
//
|
|
// This is the ONE place a member-driven status transition is written, so it is
|
|
// also where placement servability follows it (design D8): every member action
|
|
// that crosses the servable boundary — archive, restore, keep-active — reaches
|
|
// the farm through SetSiteStatusWorkflow and therefore through here. The
|
|
// direct SetSiteStatusByDomain writes elsewhere (reconcile.go's park/
|
|
// reactivate, swap.go) move only between active and readonly, both servable,
|
|
// and intentionally carry no servability write.
|
|
func (a *Activities) SetSiteStatusActivity(ctx context.Context, input SetSiteStatusInput) (*SetSiteStatusOutput, error) {
|
|
a.Logger.Info("setting FedWiki site status",
|
|
slog.String("domain", input.Domain),
|
|
slog.String("status", input.Status))
|
|
|
|
if _, err := a.Client.SetSiteStatus(ctx, input.Domain, input.Status); err != nil {
|
|
a.Logger.Error("failed to set site status via FarmManager API",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
|
|
var apiErr *FarmManagerAPIError
|
|
if errors.As(err, &apiErr) && apiErr.IsNonRetryable() {
|
|
return nil, temporal.NewNonRetryableApplicationError(
|
|
apiErr.UserMessage(),
|
|
"FarmManagerAPIError",
|
|
err,
|
|
)
|
|
}
|
|
return nil, fmt.Errorf("failed to set site status on FedWiki farm: %w", err)
|
|
}
|
|
|
|
// Project the observed status locally (the farm remains canonical; the next
|
|
// sync reconciles). archived_at is managed by the query.
|
|
site, err := a.SiteQ.SetSiteStatusByDomain(ctx, fwmod.SetSiteStatusByDomainParams{
|
|
Domain: input.Domain,
|
|
Status: input.Status,
|
|
})
|
|
if err != nil {
|
|
a.Logger.Error("failed to project site status locally",
|
|
slog.String("domain", input.Domain),
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("site status changed on farm but failed to record locally: %w", err)
|
|
}
|
|
|
|
a.setPlacementServability(ctx, site.SiteID, input.Domain, input.Status)
|
|
|
|
return &SetSiteStatusOutput{Success: true}, nil
|
|
}
|
|
|
|
// setPlacementServability keeps the site's placement in step with its
|
|
// lifecycle: archived names stop being served, active and readonly ones serve.
|
|
// Best-effort — the status change already landed on the farm and locally, so a
|
|
// registry hiccup must not fail the transition; the next sync projection
|
|
// re-asserts servability from the observed status.
|
|
func (a *Activities) setPlacementServability(ctx context.Context, siteID, domain, status string) {
|
|
if a.Registry == nil || siteID == "" {
|
|
return
|
|
}
|
|
servable := IsServableStatus(status)
|
|
if _, err := a.Registry.SetServableByResource(ctx, fwmod.ModuleName, siteID, servable); err != nil {
|
|
a.Logger.Warn("failed to update placement servability",
|
|
slog.String("domain", domain),
|
|
slog.String("status", status),
|
|
slog.Bool("servable", servable),
|
|
slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// IsServableStatus reports whether a site in this lifecycle state should have
|
|
// its name served: `active` and `readonly` do (a read-only wiki is still a
|
|
// wiki), `archived` does not.
|
|
func IsServableStatus(status string) bool { return status != "archived" }
|
|
|
|
// ListFedWikiSitesInput is the input for the ListFedWikiSites activity.
|
|
type ListFedWikiSitesInput struct{}
|
|
|
|
// ListFedWikiSitesOutput is the output for the ListFedWikiSites activity.
|
|
type ListFedWikiSitesOutput struct {
|
|
Sites []SiteInfo
|
|
}
|
|
|
|
// ListFedWikiSitesActivity lists all sites on the FedWiki farm.
|
|
func (a *Activities) ListFedWikiSitesActivity(ctx context.Context, input ListFedWikiSitesInput) (*ListFedWikiSitesOutput, error) {
|
|
a.Logger.Info("listing FedWiki sites")
|
|
|
|
sites, err := a.Client.ListSites(ctx)
|
|
if err != nil {
|
|
a.Logger.Error("failed to list sites via FarmManager API",
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("failed to list sites on FedWiki farm: %w", err)
|
|
}
|
|
|
|
a.Logger.Info("FedWiki sites listed successfully",
|
|
slog.Int("count", len(sites)))
|
|
|
|
return &ListFedWikiSitesOutput{Sites: sites}, nil
|
|
}
|
|
|
|
// CheckQuotaInput is the input for the CheckQuota activity.
|
|
type CheckQuotaInput struct {
|
|
WorkspaceID string
|
|
}
|
|
|
|
// CheckQuotaOutput is the output for the CheckQuota activity.
|
|
type CheckQuotaOutput struct {
|
|
CurrentCount int64
|
|
Quota int64
|
|
CanCreate bool
|
|
}
|
|
|
|
// CheckQuotaActivity checks if a workspace has quota available to create a new site
|
|
// by reading from the entitlements system.
|
|
func (a *Activities) CheckQuotaActivity(ctx context.Context, input CheckQuotaInput) (*CheckQuotaOutput, error) {
|
|
a.Logger.Info("checking workspace quota", slog.String("workspaceID", input.WorkspaceID))
|
|
|
|
// Converge the counter on the rows before comparing it to the limit: rows
|
|
// written outside the create workflow (import, farm sync) leave it
|
|
// understated, and an understated counter would let the create past a
|
|
// limit the member has already reached (design D2). Raise-only, so a
|
|
// concurrent create's reservation survives. Inside the activity, so the
|
|
// workflow's activity sequence — and every in-flight history — is unchanged.
|
|
if _, err := siteusage.ReconcileWorkspace(ctx, a.EntitlementsQ, a.SiteQ, input.WorkspaceID); err != nil {
|
|
return nil, fmt.Errorf("reconcile usage before quota check: %w", err)
|
|
}
|
|
|
|
assignment, err := a.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, input.WorkspaceID)
|
|
if err != nil {
|
|
a.Logger.Error("failed to get pool assignment",
|
|
slog.String("workspaceID", input.WorkspaceID),
|
|
slog.Any("error", err))
|
|
return nil, fmt.Errorf("failed to get pool assignment: %w", err)
|
|
}
|
|
|
|
ent, err := a.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: assignment.PoolID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
// No entitlement means no quota
|
|
a.Logger.Info("no sites entitlement found",
|
|
slog.String("workspaceID", input.WorkspaceID))
|
|
return &CheckQuotaOutput{
|
|
CurrentCount: 0,
|
|
Quota: 0,
|
|
CanCreate: false,
|
|
}, nil
|
|
}
|
|
|
|
usage, err := a.EntitlementsQ.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
|
|
PoolID: assignment.PoolID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get usage: %w", err)
|
|
}
|
|
|
|
canCreate := usage.CurrentUsage < ent.ResourceLimit
|
|
|
|
a.Logger.Info("quota check completed",
|
|
slog.String("workspaceID", input.WorkspaceID),
|
|
slog.Int64("currentUsage", usage.CurrentUsage),
|
|
slog.Int64("resourceLimit", ent.ResourceLimit),
|
|
slog.Bool("canCreate", canCreate))
|
|
|
|
return &CheckQuotaOutput{
|
|
CurrentCount: usage.CurrentUsage,
|
|
Quota: ent.ResourceLimit,
|
|
CanCreate: canCreate,
|
|
}, nil
|
|
}
|
|
|
|
// IncrementUsageInput is the input for the IncrementUsage activity.
|
|
type IncrementUsageInput struct {
|
|
WorkspaceID string
|
|
}
|
|
|
|
// IncrementUsageActivity atomically increments usage for the sites entitlement.
|
|
// Returns an error if the limit has been reached (zero rows updated).
|
|
func (a *Activities) IncrementUsageActivity(ctx context.Context, input IncrementUsageInput) error {
|
|
a.Logger.Info("incrementing site usage", slog.String("workspaceID", input.WorkspaceID))
|
|
|
|
entQ := entitlements.New(a.Database)
|
|
result, err := entQ.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
|
|
WorkspaceID: input.WorkspaceID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("atomic increment: %w", err)
|
|
}
|
|
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("check rows affected: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return temporal.NewNonRetryableApplicationError(
|
|
"Site quota exceeded",
|
|
"QuotaExceeded",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
a.Logger.Info("site usage incremented", slog.String("workspaceID", input.WorkspaceID))
|
|
return nil
|
|
}
|
|
|
|
// DecrementUsageInput is the input for the DecrementUsage activity.
|
|
type DecrementUsageInput struct {
|
|
WorkspaceID string
|
|
}
|
|
|
|
// DecrementUsageActivity atomically decrements usage for the sites entitlement.
|
|
func (a *Activities) DecrementUsageActivity(ctx context.Context, input DecrementUsageInput) error {
|
|
a.Logger.Info("decrementing site usage", slog.String("workspaceID", input.WorkspaceID))
|
|
|
|
entQ := entitlements.New(a.Database)
|
|
_, err := entQ.AtomicDecrementUsage(ctx, entitlements.AtomicDecrementUsageParams{
|
|
WorkspaceID: input.WorkspaceID,
|
|
ResourceKey: "fedwiki_sites",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("atomic decrement: %w", err)
|
|
}
|
|
|
|
a.Logger.Info("site usage decremented", slog.String("workspaceID", input.WorkspaceID))
|
|
return nil
|
|
}
|