Files
cgalo5758 8d05934e93 Add domains registry with claims and placements
Domain names become an allocatable resource with one authority. A new
core module (schema `domains`, own migration stream between core and the
integrations) owns claims — a DNS node plus its whole subtree, mutually
disjoint: operator shared-domain roots, member claims carved from them,
and bring-your-own names proven by TXT verification — and placements,
which bind a name inside a claim to a provider slug and resource ref.

Verification moves to the claim and decouples from creation. A member
proves control of a domain once; afterwards every name inside it places
instantly, wildcard-CNAME friendly, with no further DNS work. The claim
workflow activates the claim and stops — it no longer creates a site —
so the sites list offers a one-click create once a domain verifies.

/domains/ask answers from placements and is registered by core rather
than the FedWiki adapter; its HTTP contract is unchanged. A configured
`domains-ask-fallback-url` forwards names the registry does not know to
a legacy answerer, the strangler seam wiki.cafe's migration needs; a
name the registry knows but has archived is refused locally.

FedWiki's create saga reserves the name before the farm call, carrying a
workflow-minted site id so retries are idempotent, and compensates on
failure. Sync places only names it owns, never stealing a member's;
lifecycle transitions and the retention purge maintain servability. An
unconditional boot pass seeds operator roots, releases orphaned
placements, and adopts pre-existing sites — grandfathering member-owned
external domains shortest-name-first, and skipping name policy, so a
live single-letter site cannot lose its certificate.

Members manage domains at /domains: claims with verification status, DNS
records including an optional wildcard row, check-now, cancel, release.
Name policy (reserved, blocked, premium, plus a single-letter guard) is
operator data; refusals collapse to a plain "unavailable" so the console
never becomes an oracle for who holds what.

BREAKING (pre-release): `fedwiki.custom_domain_verifications` and
`sites.is_custom_domain` are dropped, the flag now derived from the
placement's claim kind; resource key `fedwiki_custom_domains` migrates
to the platform-owned `external_domain_claims`; running
verify-custom-domain workflows must be terminated before deploy.
2026-07-24 21:25:40 -05:00

436 lines
17 KiB
Go

package workflows
import (
"errors"
"fmt"
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// FedWikiActivityOptions returns activity options for FedWiki API calls.
func FedWikiActivityOptions() workflow.ActivityOptions {
return workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 5 * time.Minute,
MaximumAttempts: 8,
NonRetryableErrorTypes: []string{
"QuotaExceeded",
"InvalidDomain",
"Unauthorized",
"DomainAlreadyExists",
"FarmManagerAPIError",
},
},
}
}
// LocalActivityOptions returns activity options for local database operations.
func LocalActivityOptions() workflow.ActivityOptions {
return workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 30 * time.Second,
MaximumAttempts: 3,
},
}
}
// CreateFedWikiSiteWorkflowInput is the input for the CreateFedWikiSiteWorkflow.
type CreateFedWikiSiteWorkflowInput struct {
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
SupportURL string
}
// CreateFedWikiSiteWorkflowOutput is the output for the CreateFedWikiSiteWorkflow.
type CreateFedWikiSiteWorkflowOutput struct {
Success bool
SiteID string
Domain string
ErrorMessage string
}
// CreateFedWikiSiteWorkflow orchestrates the creation of a new FedWiki site.
// It uses a saga pattern with two reservations taken before the irreversible
// external work: quota (IncrementUsageActivity) and then the name itself
// (AllocateDomainActivity, which returns the site id the placement is bound
// to). Only then does it call the farm. A farm-side failure compensates both,
// registry first — the lock-ordering invariant in design D3.
func CreateFedWikiSiteWorkflow(ctx workflow.Context, input CreateFedWikiSiteWorkflowInput) (*CreateFedWikiSiteWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
logger.Info("CreateFedWikiSiteWorkflow started",
"workspaceID", input.WorkspaceID,
"domain", input.Domain)
var activities *Activities
// Step 1: Check quota (read-only, fast)
localCtx := workflow.WithActivityOptions(ctx, LocalActivityOptions())
var quotaResult *CheckQuotaOutput
err := workflow.ExecuteActivity(localCtx, activities.CheckQuotaActivity, CheckQuotaInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, &quotaResult)
if err != nil {
logger.Error("Failed to check quota", "error", err)
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: "Failed to verify your site quota. Please try again later.",
}, nil
}
if !quotaResult.CanCreate {
logger.Info("Workspace quota exceeded",
"workspaceID", input.WorkspaceID,
"currentCount", quotaResult.CurrentCount,
"quota", quotaResult.Quota)
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: fmt.Sprintf("You have reached your site limit (%d of %d sites). Join us and become a member for more sites.", quotaResult.CurrentCount, quotaResult.Quota),
}, nil
}
// Step 2: Atomically increment usage (reserve the slot)
err = workflow.ExecuteActivity(localCtx, activities.IncrementUsageActivity, IncrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil)
if err != nil {
logger.Error("Failed to increment usage", "error", err)
var appErr *temporal.ApplicationError
if errors.As(err, &appErr) && appErr.Type() == "QuotaExceeded" {
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: fmt.Sprintf("You have reached your site limit (%d of %d sites). Join us and become a member for more sites.", quotaResult.CurrentCount, quotaResult.Quota),
}, nil
}
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: "Failed to reserve site quota. Please try again later.",
}, nil
}
// Step 3: Reserve the name in the domains registry — after the quota
// reserve, BEFORE the farm call (design D8). One registry transaction
// ensures the claim (auto-carving a member claim for a hosted label) and
// records the placement against the site id minted just below, so nobody
// else can take the name across the farm's multi-minute retry window and
// the sites row can later be inserted with the identity the registry
// already holds.
//
// The id is minted in workflow state, through a side effect, so every
// attempt of the allocation activity reserves against the SAME id. Minting
// it inside the activity would give a retry (worker crash, lost result) a
// fresh id, which would then collide with the placement the first attempt
// had already committed and refuse the member their own name.
var siteID string
if err := workflow.SideEffect(ctx, func(workflow.Context) any {
return NewSiteID()
}).Get(&siteID); err != nil {
logger.Error("Failed to mint site id", "error", err)
if compErr := workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil); compErr != nil {
logger.Error("Failed to compensate usage decrement", "error", compErr)
}
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: "We couldn't reserve that name for your site. Please try a different one.",
}, nil
}
var allocateResult *AllocateDomainOutput
err = workflow.ExecuteActivity(localCtx, activities.AllocateDomainActivity, AllocateDomainInput{
SiteID: siteID,
WorkspaceID: input.WorkspaceID,
Domain: input.Domain,
SiteDomain: input.SiteDomain,
IsCustomDomain: input.IsCustomDomain,
}).Get(ctx, &allocateResult)
if err != nil {
logger.Error("Failed to allocate site domain, compensating usage", "error", err)
// Nothing was placed, so the quota reserve is the only thing to undo.
// A refusal commits nothing, and an attempt that committed but lost
// its result is re-run against the same site id, so it reaches the
// reservation it already made rather than failing here. Boot
// reconciliation's orphan sweep covers the residue of an exhausted
// retry, which is the only way a placement outlives this branch.
if compErr := workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil); compErr != nil {
logger.Error("Failed to compensate usage decrement", "error", compErr)
}
var appErr *temporal.ApplicationError
errorMsg := "We couldn't reserve that name for your site. Please try a different one."
if errors.As(err, &appErr) {
errorMsg = appErr.Message()
}
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: errorMsg,
}, nil
}
// Step 4: Create the site on the external farm (with retries)
fedwikiCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
var createResult *CreateFedWikiSiteOutput
err = workflow.ExecuteActivity(fedwikiCtx, activities.CreateFedWikiSiteActivity, CreateFedWikiSiteInput{
SiteID: allocateResult.SiteID,
WorkspaceID: input.WorkspaceID,
Domain: input.Domain,
SiteDomain: input.SiteDomain,
OwnerName: input.OwnerName,
OwnerID: input.OwnerID,
IsCustomDomain: input.IsCustomDomain,
}).Get(ctx, &createResult)
if err != nil {
logger.Error("Failed to create site after retries, compensating allocation and usage", "error", err)
// Compensate in reverse allocation order, registry first: the
// lock-ordering invariant (design D3) puts the registry lock ahead of
// the entitlement-usage row lock, so create and release never grab the
// two in opposite orders and deadlock.
if compErr := workflow.ExecuteActivity(localCtx, activities.ReleaseDomainActivity, ReleaseDomainInput{
SiteID: allocateResult.SiteID,
}).Get(ctx, nil); compErr != nil {
logger.Error("Failed to compensate domain allocation", "error", compErr)
}
// Compensate: decrement usage since site creation failed
compErr := workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil)
if compErr != nil {
logger.Error("Failed to compensate usage decrement", "error", compErr)
}
var appErr *temporal.ApplicationError
var errorMsg string
if errors.As(err, &appErr) {
errorMsg = appErr.Message()
} else {
errorMsg = "We encountered an issue creating your site. Our system tried multiple times but was unable to complete the request. "
errorMsg += "Please try again later. "
if input.SupportURL != "" {
errorMsg += fmt.Sprintf("If the problem persists, please contact support: %s", input.SupportURL)
}
}
return &CreateFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: errorMsg,
}, nil
}
logger.Info("CreateFedWikiSiteWorkflow completed successfully",
"siteID", createResult.SiteID,
"domain", createResult.Domain)
return &CreateFedWikiSiteWorkflowOutput{
Success: true,
SiteID: createResult.SiteID,
Domain: createResult.Domain,
}, nil
}
// SetSiteStatusWorkflowInput is the input for the SetSiteStatusWorkflow.
type SetSiteStatusWorkflowInput struct {
Domain string // Full domain as stored in the database
WorkspaceID string // Workspace that owns the site, for usage adjustment
CurrentStatus string // active | readonly | archived (the observed local status)
TargetStatus string // active | readonly | archived
SupportURL string
}
// SetSiteStatusWorkflowOutput is the output for the SetSiteStatusWorkflow.
type SetSiteStatusWorkflowOutput struct {
Success bool
ErrorMessage string
}
// SetSiteStatusWorkflow moves a site between lifecycle states (active/readonly/
// archived) via FarmManager PATCH, keeping active-usage in step. Because quota
// counts only active sites, a transition INTO active reserves a slot first
// (quota-gated, compensated if the farm call fails) and a transition OUT of
// active releases one — mirroring the create/delete sagas.
func SetSiteStatusWorkflow(ctx workflow.Context, input SetSiteStatusWorkflowInput) (*SetSiteStatusWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
logger.Info("SetSiteStatusWorkflow started",
"domain", input.Domain,
"from", input.CurrentStatus,
"to", input.TargetStatus)
var activities *Activities
localCtx := workflow.WithActivityOptions(ctx, LocalActivityOptions())
fedwikiCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
const active = "active"
if input.CurrentStatus == input.TargetStatus {
return &SetSiteStatusWorkflowOutput{Success: true}, nil // idempotent no-op
}
activating := input.CurrentStatus != active && input.TargetStatus == active
deactivating := input.CurrentStatus == active && input.TargetStatus != active
// Reserve a quota slot before activating on the farm.
if activating && input.WorkspaceID != "" {
err := workflow.ExecuteActivity(localCtx, activities.IncrementUsageActivity, IncrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil)
if err != nil {
var appErr *temporal.ApplicationError
if errors.As(err, &appErr) && appErr.Type() == "QuotaExceeded" {
return &SetSiteStatusWorkflowOutput{
Success: false,
ErrorMessage: "You have reached your active site limit. Free up a slot before reactivating this site.",
}, nil
}
return &SetSiteStatusWorkflowOutput{
Success: false,
ErrorMessage: "Failed to reserve site quota. Please try again later.",
}, nil
}
}
// Apply the status change on the farm + project locally.
var statusResult *SetSiteStatusOutput
err := workflow.ExecuteActivity(fedwikiCtx, activities.SetSiteStatusActivity, SetSiteStatusInput{
Domain: input.Domain,
Status: input.TargetStatus,
}).Get(ctx, &statusResult)
if err != nil {
logger.Error("Failed to set site status after retries", "error", err)
if activating && input.WorkspaceID != "" {
// Compensate the reserved slot.
if compErr := workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil); compErr != nil {
logger.Error("Failed to compensate usage after status change failure", "error", compErr)
}
}
var appErr *temporal.ApplicationError
var errorMsg string
if errors.As(err, &appErr) {
errorMsg = appErr.Message()
} else {
errorMsg = "We couldn't change your site's status. Our system tried multiple times but was unable to complete the request. Please try again later. "
if input.SupportURL != "" {
errorMsg += fmt.Sprintf("If the problem persists, please contact support: %s", input.SupportURL)
}
}
return &SetSiteStatusWorkflowOutput{Success: false, ErrorMessage: errorMsg}, nil
}
// Release the slot after deactivating.
if deactivating && input.WorkspaceID != "" {
if err := workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil); err != nil {
// The status change already succeeded; log but don't fail.
logger.Error("Failed to decrement usage after deactivation", "error", err)
}
}
logger.Info("SetSiteStatusWorkflow completed", "domain", input.Domain, "status", input.TargetStatus)
return &SetSiteStatusWorkflowOutput{Success: true}, nil
}
// DeleteFedWikiSiteWorkflowInput is the input for the DeleteFedWikiSiteWorkflow.
type DeleteFedWikiSiteWorkflowInput struct {
Domain string // Full domain as stored in database
WorkspaceID string // Workspace that owns the site, for usage decrement
// WasActive records whether the site was still counted in active usage at
// the moment of deletion (i.e. its local status was "active"). Sites that
// were already readonly/archived already released their active-usage slot
// when they left the active state (see SetSiteStatusWorkflow's deactivating
// branch); deleting them must NOT decrement again, or usage undercounts and
// the workspace can create sites past its real limit. Caller-supplied
// (mirroring SetSiteStatusWorkflowInput.CurrentStatus) rather than
// re-derived here: every caller already fetches the site row to check
// ownership before starting this workflow, so the data is fresher there
// than a redundant in-workflow query could make it, and keeping it as an
// explicit, dedicated field (rather than overloading WorkspaceID's
// presence/absence to also mean "was active") avoids repeating the exact
// bug class in #30, where WorkspaceID was simply omitted.
WasActive bool
SupportURL string
}
// DeleteFedWikiSiteWorkflowOutput is the output for the DeleteFedWikiSiteWorkflow.
type DeleteFedWikiSiteWorkflowOutput struct {
Success bool
ErrorMessage string
}
// DeleteFedWikiSiteWorkflow orchestrates the deletion of a FedWiki site
// and decrements entitlement usage on success.
func DeleteFedWikiSiteWorkflow(ctx workflow.Context, input DeleteFedWikiSiteWorkflowInput) (*DeleteFedWikiSiteWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
logger.Info("DeleteFedWikiSiteWorkflow started",
"domain", input.Domain,
"workspaceID", input.WorkspaceID)
var activities *Activities
// Step 1: Delete the site (external API + local DB)
fedwikiCtx := workflow.WithActivityOptions(ctx, FedWikiActivityOptions())
var deleteResult *DeleteFedWikiSiteOutput
err := workflow.ExecuteActivity(fedwikiCtx, activities.DeleteFedWikiSiteActivity, DeleteFedWikiSiteInput{
Domain: input.Domain,
}).Get(ctx, &deleteResult)
if err != nil {
logger.Error("Failed to delete site after retries", "error", err)
var appErr *temporal.ApplicationError
var errorMsg string
if errors.As(err, &appErr) {
errorMsg = appErr.Message()
} else {
errorMsg = "We encountered an issue deleting your site. Our system tried multiple times but was unable to complete the request. "
errorMsg += "Please try again later. "
if input.SupportURL != "" {
errorMsg += fmt.Sprintf("If the problem persists, please contact support: %s", input.SupportURL)
}
}
return &DeleteFedWikiSiteWorkflowOutput{
Success: false,
ErrorMessage: errorMsg,
}, nil
}
// Step 2: Decrement usage after successful deletion — only if the site was
// still active (see WasActive doc comment); readonly/archived sites already
// released their slot when they left the active state.
if input.WorkspaceID != "" && input.WasActive {
localCtx := workflow.WithActivityOptions(ctx, LocalActivityOptions())
err = workflow.ExecuteActivity(localCtx, activities.DecrementUsageActivity, DecrementUsageInput{
WorkspaceID: input.WorkspaceID,
}).Get(ctx, nil)
if err != nil {
logger.Error("Failed to decrement usage after site deletion", "error", err)
// Site is already deleted, so we log but don't fail the workflow
}
}
logger.Info("DeleteFedWikiSiteWorkflow completed successfully",
"domain", input.Domain)
return &DeleteFedWikiSiteWorkflowOutput{
Success: true,
}, nil
}