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.
436 lines
17 KiB
Go
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, "aResult)
|
|
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
|
|
}
|