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.
273 lines
10 KiB
Go
273 lines
10 KiB
Go
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/mock"
|
|
"go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/testsuite"
|
|
)
|
|
|
|
// createSagaRecorder mocks every activity CreateFedWikiSiteWorkflow can run and
|
|
// records the call order plus the inputs the assertions care about. A nil
|
|
// *Activities is sufficient: OnActivity reflects on the method value only to
|
|
// derive the registered activity name, and the mock intercepts before the (nil)
|
|
// receiver is dereferenced — the same idiom sync_test.go uses.
|
|
type createSagaRecorder struct {
|
|
order []string
|
|
createInput CreateFedWikiSiteInput
|
|
releasedSiteID string
|
|
}
|
|
|
|
const testAllocatedSiteID = "01927f2c-0000-7000-8000-00000000abcd"
|
|
|
|
// registerCreateSagaMocks wires the four always-present activities. farmErr, when
|
|
// non-nil, fails the farm-side create so the compensation path runs; allocErr
|
|
// fails the allocation instead (and the farm call must then never happen).
|
|
func registerCreateSagaMocks(env *testsuite.TestWorkflowEnvironment, rec *createSagaRecorder, allocErr, farmErr error) {
|
|
var acts *Activities
|
|
|
|
env.OnActivity(acts.CheckQuotaActivity, mock.Anything, mock.Anything).
|
|
Run(func(mock.Arguments) { rec.order = append(rec.order, "check-quota") }).
|
|
Return(&CheckQuotaOutput{CurrentCount: 0, Quota: 3, CanCreate: true}, nil)
|
|
|
|
env.OnActivity(acts.IncrementUsageActivity, mock.Anything, mock.Anything).
|
|
Run(func(mock.Arguments) { rec.order = append(rec.order, "increment-usage") }).
|
|
Return(nil)
|
|
|
|
allocate := env.OnActivity(acts.AllocateDomainActivity, mock.Anything, mock.Anything).
|
|
Run(func(mock.Arguments) { rec.order = append(rec.order, "allocate-domain") })
|
|
if allocErr != nil {
|
|
allocate.Return(nil, allocErr)
|
|
} else {
|
|
allocate.Return(&AllocateDomainOutput{SiteID: testAllocatedSiteID, Domain: "mysite.localtest.me"}, nil)
|
|
}
|
|
|
|
create := env.OnActivity(acts.CreateFedWikiSiteActivity, mock.Anything, mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
rec.order = append(rec.order, "create-site")
|
|
if in, ok := args.Get(1).(CreateFedWikiSiteInput); ok {
|
|
rec.createInput = in
|
|
}
|
|
})
|
|
if farmErr != nil {
|
|
create.Return(nil, farmErr)
|
|
} else {
|
|
create.Return(&CreateFedWikiSiteOutput{SiteID: testAllocatedSiteID, Domain: "mysite.localtest.me"}, nil)
|
|
}
|
|
|
|
env.OnActivity(acts.ReleaseDomainActivity, mock.Anything, mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
rec.order = append(rec.order, "release-domain")
|
|
if in, ok := args.Get(1).(ReleaseDomainInput); ok {
|
|
rec.releasedSiteID = in.SiteID
|
|
}
|
|
}).
|
|
Return(nil)
|
|
|
|
env.OnActivity(acts.DecrementUsageActivity, mock.Anything, mock.Anything).
|
|
Run(func(mock.Arguments) { rec.order = append(rec.order, "decrement-usage") }).
|
|
Return(nil)
|
|
}
|
|
|
|
func createSagaInput() CreateFedWikiSiteWorkflowInput {
|
|
return CreateFedWikiSiteWorkflowInput{
|
|
WorkspaceID: "workspace-abc",
|
|
Domain: "mysite",
|
|
SiteDomain: "localtest.me",
|
|
OwnerName: "Tester",
|
|
OwnerID: "sub-tester",
|
|
}
|
|
}
|
|
|
|
// TestCreateSagaReusesTheSiteIDAcrossAllocationRetries reproduces the
|
|
// at-least-once hazard the reservation has to survive: the allocation activity
|
|
// commits its placement and then loses its result (worker crash, dropped
|
|
// connection), so Temporal runs it again.
|
|
//
|
|
// Every attempt MUST carry the same site id. An id minted inside the activity
|
|
// differs per attempt, so the retry reserves under a fresh id, collides with
|
|
// the placement its own first attempt committed, and — because a namespace
|
|
// refusal is permanent by construction — strands that placement and its carved
|
|
// claim while telling the member the name is taken. Minting it in workflow
|
|
// state (a side effect, recorded in history) is what makes the retry converge.
|
|
func TestCreateSagaReusesTheSiteIDAcrossAllocationRetries(t *testing.T) {
|
|
var suite testsuite.WorkflowTestSuite
|
|
env := suite.NewTestWorkflowEnvironment()
|
|
|
|
var acts *Activities
|
|
env.OnActivity(acts.CheckQuotaActivity, mock.Anything, mock.Anything).
|
|
Return(&CheckQuotaOutput{CurrentCount: 0, Quota: 3, CanCreate: true}, nil)
|
|
env.OnActivity(acts.IncrementUsageActivity, mock.Anything, mock.Anything).Return(nil)
|
|
|
|
var seen []string
|
|
env.OnActivity(acts.AllocateDomainActivity, mock.Anything, mock.Anything).
|
|
Return(func(_ context.Context, in AllocateDomainInput) (*AllocateDomainOutput, error) {
|
|
seen = append(seen, in.SiteID)
|
|
if len(seen) == 1 {
|
|
// Committed, then the result was lost on the way back.
|
|
return nil, errors.New("connection reset")
|
|
}
|
|
return &AllocateDomainOutput{SiteID: in.SiteID, Domain: "mysite.localtest.me"}, nil
|
|
})
|
|
env.OnActivity(acts.CreateFedWikiSiteActivity, mock.Anything, mock.Anything).
|
|
Return(func(_ context.Context, in CreateFedWikiSiteInput) (*CreateFedWikiSiteOutput, error) {
|
|
return &CreateFedWikiSiteOutput{SiteID: in.SiteID, Domain: in.Domain}, nil
|
|
})
|
|
|
|
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
|
|
|
|
if !env.IsWorkflowCompleted() {
|
|
t.Fatal("workflow did not complete")
|
|
}
|
|
if err := env.GetWorkflowError(); err != nil {
|
|
t.Fatalf("workflow error: %v", err)
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Fatalf("allocation ran %d time(s), want the retry the test sets up", len(seen))
|
|
}
|
|
if seen[0] == "" {
|
|
t.Error("the workflow supplied no site id; the activity would have to mint one per attempt")
|
|
}
|
|
for i, got := range seen[1:] {
|
|
if got != seen[0] {
|
|
t.Errorf("attempt %d reserved under site id %q, want the first attempt's %q", i+2, got, seen[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func indexOf(order []string, name string) int {
|
|
for i, got := range order {
|
|
if got == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// TestCreateSagaReservesNameBeforeFarmCall pins the reserve-then-bind ordering
|
|
// (design D8): quota is reserved, then the name, and only then is the farm
|
|
// called — with the site id the allocation pre-generated, so the fedwiki.sites
|
|
// row carries the identity the placement already points at.
|
|
func TestCreateSagaReservesNameBeforeFarmCall(t *testing.T) {
|
|
var suite testsuite.WorkflowTestSuite
|
|
env := suite.NewTestWorkflowEnvironment()
|
|
|
|
rec := &createSagaRecorder{}
|
|
registerCreateSagaMocks(env, rec, nil, nil)
|
|
|
|
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
|
|
|
|
if !env.IsWorkflowCompleted() {
|
|
t.Fatal("workflow did not complete")
|
|
}
|
|
if err := env.GetWorkflowError(); err != nil {
|
|
t.Fatalf("workflow error: %v", err)
|
|
}
|
|
var out *CreateFedWikiSiteWorkflowOutput
|
|
if err := env.GetWorkflowResult(&out); err != nil {
|
|
t.Fatalf("get result: %v", err)
|
|
}
|
|
if !out.Success {
|
|
t.Fatalf("Success = false, want true (%q)", out.ErrorMessage)
|
|
}
|
|
|
|
want := []string{"check-quota", "increment-usage", "allocate-domain", "create-site"}
|
|
if len(rec.order) != len(want) {
|
|
t.Fatalf("activity order = %v, want %v", rec.order, want)
|
|
}
|
|
for i := range want {
|
|
if rec.order[i] != want[i] {
|
|
t.Fatalf("activity order = %v, want %v", rec.order, want)
|
|
}
|
|
}
|
|
if rec.createInput.SiteID != testAllocatedSiteID {
|
|
t.Errorf("CreateFedWikiSiteInput.SiteID = %q, want the allocated id %q", rec.createInput.SiteID, testAllocatedSiteID)
|
|
}
|
|
}
|
|
|
|
// TestCreateSagaCompensatesAllocationOnFarmFailure asserts the compensation
|
|
// path: a farm-side failure frees the reserved name AND the reserved quota
|
|
// slot, registry first (the design D3 lock-ordering invariant).
|
|
func TestCreateSagaCompensatesAllocationOnFarmFailure(t *testing.T) {
|
|
var suite testsuite.WorkflowTestSuite
|
|
env := suite.NewTestWorkflowEnvironment()
|
|
|
|
rec := &createSagaRecorder{}
|
|
farmErr := temporal.NewNonRetryableApplicationError(
|
|
"The FedWiki farm could not create your site.", "FarmManagerAPIError", nil)
|
|
registerCreateSagaMocks(env, rec, nil, farmErr)
|
|
|
|
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
|
|
|
|
if !env.IsWorkflowCompleted() {
|
|
t.Fatal("workflow did not complete")
|
|
}
|
|
if err := env.GetWorkflowError(); err != nil {
|
|
t.Fatalf("workflow returned an error, want friendly output: %v", err)
|
|
}
|
|
var out *CreateFedWikiSiteWorkflowOutput
|
|
if err := env.GetWorkflowResult(&out); err != nil {
|
|
t.Fatalf("get result: %v", err)
|
|
}
|
|
if out.Success {
|
|
t.Error("Success = true, want false after a farm failure")
|
|
}
|
|
|
|
release, decrement := indexOf(rec.order, "release-domain"), indexOf(rec.order, "decrement-usage")
|
|
if release < 0 {
|
|
t.Fatalf("ReleaseDomainActivity was never called; order = %v", rec.order)
|
|
}
|
|
if decrement < 0 {
|
|
t.Fatalf("DecrementUsageActivity was never called; order = %v", rec.order)
|
|
}
|
|
if release > decrement {
|
|
t.Errorf("compensation order = %v, want the registry release before the usage decrement", rec.order)
|
|
}
|
|
if rec.releasedSiteID != testAllocatedSiteID {
|
|
t.Errorf("released site id = %q, want the allocated id %q", rec.releasedSiteID, testAllocatedSiteID)
|
|
}
|
|
}
|
|
|
|
// TestCreateSagaAllocationFailureSkipsFarmAndReleases asserts that a refused
|
|
// name never reaches the farm, compensates only the quota slot (nothing was
|
|
// placed to release), and surfaces the activity's member-facing message.
|
|
func TestCreateSagaAllocationFailureSkipsFarmAndReleases(t *testing.T) {
|
|
var suite testsuite.WorkflowTestSuite
|
|
env := suite.NewTestWorkflowEnvironment()
|
|
|
|
rec := &createSagaRecorder{}
|
|
const refusal = "That name isn't available. Please choose another."
|
|
registerCreateSagaMocks(env, rec, temporal.NewNonRetryableApplicationError(refusal, "InvalidDomain", nil), nil)
|
|
|
|
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
|
|
|
|
if !env.IsWorkflowCompleted() {
|
|
t.Fatal("workflow did not complete")
|
|
}
|
|
if err := env.GetWorkflowError(); err != nil {
|
|
t.Fatalf("workflow returned an error, want friendly output: %v", err)
|
|
}
|
|
var out *CreateFedWikiSiteWorkflowOutput
|
|
if err := env.GetWorkflowResult(&out); err != nil {
|
|
t.Fatalf("get result: %v", err)
|
|
}
|
|
if out.Success {
|
|
t.Error("Success = true, want false when the name could not be allocated")
|
|
}
|
|
if out.ErrorMessage != refusal {
|
|
t.Errorf("ErrorMessage = %q, want the activity's refusal %q", out.ErrorMessage, refusal)
|
|
}
|
|
if i := indexOf(rec.order, "create-site"); i >= 0 {
|
|
t.Errorf("the farm was called despite a failed allocation; order = %v", rec.order)
|
|
}
|
|
if i := indexOf(rec.order, "release-domain"); i >= 0 {
|
|
t.Errorf("ReleaseDomainActivity ran with nothing placed; order = %v", rec.order)
|
|
}
|
|
if i := indexOf(rec.order, "decrement-usage"); i < 0 {
|
|
t.Errorf("the reserved quota slot was not compensated; order = %v", rec.order)
|
|
}
|
|
}
|