Files
member-console/internal/server/operator_domains.go
T

259 lines
9.9 KiB
Go

// Operator Domains surface (claim-lifecycle-hardening design D7): the
// deployment-wide view of who holds which name, plus the one moderation write
// — force-release — an operator has against a claim they do not own.
//
// It exists because every other domains surface is workspace-scoped: a member
// sees their own claims and nothing else, which is right for them and useless
// for the operator answering "who is sitting on this name". The page also
// prints the effective claim policy, since those keys are core flags that
// appear on no integration settings page — this is the only place a
// deployment's window, caps, and budgets are visible at all.
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
wfdomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains"
)
// OperatorDomainClaimRow is one live claim on the moderation surface.
type OperatorDomainClaimRow struct {
ClaimID string
Root string
Kind string
Status string
StatusClass string
OrgID string
OrgName string
Workspace string
// Age is how long the claim has held the name — the column that makes a
// squatted name legible, which a creation timestamp does not.
Age string
Placements int64
// Evidence reports the zone-control latch: this holder published our
// challenge value at some point, so their abandoning the name is never
// counted against them automatically.
Evidence bool
// Releasable is false for operator roots, which the registry refuses to
// release (they are ensured from configuration at every boot), AND for
// claims that still hold placements, which the registry's placement
// guard refuses identically (a name in use is freed by removing its
// sites, not by moderation). The affordance is not offered where the
// answer is always no — a rendered button the server will refuse is the
// dead-end-affordance failure mode (issues.md, found 2026-07-26 when the
// domains walkthrough hit it).
Releasable bool
// Placed marks the unreleasable-because-in-use case so the template can
// label it honestly instead of calling everything "operator root".
Placed bool
}
// OperatorDomainPolicyView is the effective claim policy, formatted for
// display. Every field is what the registry actually allocates under, not
// what any config file says: unset keys resolve to the package defaults, and
// only the resolved set is true.
type OperatorDomainPolicyView struct {
ClaimWindow string
PendingCap string
AbandonBudget string
AbandonWindow string
ScopeLabels string
InitiationBudget string
}
// OperatorDomainsData feeds operator_domains.html.
type OperatorDomainsData struct {
Claims []OperatorDomainClaimRow
Policy OperatorDomainPolicyView
Error string
}
// domainClaimStatusClass maps a claim status to its badge class. Operators see
// the registry's own vocabulary — unlike members, whose surface translates it
// (member_domains.go's domainStatusLabel) — because the stored value is what
// they will match against logs and the database.
func domainClaimStatusClass(status string) string {
switch status {
case domains.StatusPending:
return "text-bg-warning"
case domains.StatusActive:
return "text-bg-success"
default:
return "text-bg-secondary"
}
}
// claimAge renders how long a claim has held its name in the largest unit that
// still says something. The question this column answers is "how long has this
// been sitting here", which an absolute timestamp answers slowly.
func claimAge(since time.Time) string {
elapsed := time.Since(since)
switch {
case elapsed < time.Minute:
return "just now"
case elapsed < time.Hour:
return fmt.Sprintf("%dm", int(elapsed.Minutes()))
case elapsed < 24*time.Hour:
return fmt.Sprintf("%dh", int(elapsed.Hours()))
default:
return fmt.Sprintf("%dd", int(elapsed.Hours()/24))
}
}
// formatPolicyWindow renders a policy duration in the unit it was configured
// in. time.Duration's own String would print the 7-day abandonment window as
// "168h0m0s", which is the same number and none of the meaning.
func formatPolicyWindow(window time.Duration) string {
switch {
case window <= 0:
return "—"
case window%(24*time.Hour) == 0:
return fmt.Sprintf("%dd", int(window/(24*time.Hour)))
case window%time.Hour == 0:
return fmt.Sprintf("%dh", int(window/time.Hour))
default:
return window.String()
}
}
// formatPolicyBudget renders a budget, naming a zero for what it is: that
// check disabled, not a budget of nothing.
func formatPolicyBudget(budget int) string {
if budget <= 0 {
return "disabled"
}
return fmt.Sprintf("%d", budget)
}
// loadDomainsPageData reads every live claim in the deployment plus the
// effective policy. A read failure yields the banner rather than an empty
// table, so "no claims" never silently means "the query broke".
func (h *OperatorPartialsHandler) loadDomainsPageData(ctx context.Context, errMsg string) OperatorDomainsData {
data := OperatorDomainsData{Error: errMsg}
if h.Registry == nil {
data.Error = "The domain registry isn't available."
return data
}
policy := h.Registry.Policy()
data.Policy = OperatorDomainPolicyView{
ClaimWindow: formatPolicyWindow(policy.ClaimWindow),
PendingCap: formatPolicyBudget(policy.PendingCap),
AbandonBudget: formatPolicyBudget(policy.AbandonBudget),
AbandonWindow: formatPolicyWindow(policy.AbandonWindow),
ScopeLabels: fmt.Sprintf("%d", policy.ScopeLabels),
InitiationBudget: formatPolicyBudget(policy.InitiationBudget),
}
rows, err := h.Registry.ListAllLiveClaims(ctx)
if err != nil {
h.Logger.Error("failed to list live domain claims", slog.Any("error", err))
data.Error = "Failed to load domain claims."
return data
}
data.Claims = make([]OperatorDomainClaimRow, len(rows))
for i, row := range rows {
data.Claims[i] = OperatorDomainClaimRow{
ClaimID: row.ClaimID,
Root: row.RootFqdn,
Kind: row.Kind,
Status: row.Status,
StatusClass: domainClaimStatusClass(row.Status),
OrgID: row.OrgID,
OrgName: row.OrgName,
Workspace: row.WorkspaceName,
Age: claimAge(row.CreatedAt),
Placements: row.PlacementCount,
Evidence: row.EvidenceAt.Valid,
Releasable: row.Kind != domains.KindOperatorRoot && row.PlacementCount == 0,
Placed: row.Kind != domains.KindOperatorRoot && row.PlacementCount > 0,
}
}
return data
}
// GetDomainsPage handles GET /operator/domains — live claims across every
// workspace, newest-pending-first, with the effective claim policy.
func (h *OperatorPartialsHandler) GetDomainsPage(w http.ResponseWriter, r *http.Request) {
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:domains"
page.ActiveCapability = "domains"
page.BodyTemplate = "operator_domains.html"
page.BodyData = h.loadDomainsPageData(r.Context(), "")
h.Templates.Render(w, "operator.html", page)
}
// ForceReleaseClaim handles POST /partials/operator/domains/{claimID}/force-release
// — the moderation write. The registry frees the name under its own lock, with
// the placement guard and the operator-root refusal members get; only ownership
// is waived. The verification workflow is stopped afterwards, best-effort: the
// claim is already terminal, and an orphaned poller writes nothing (every probe
// write is pending-guarded) and finishes on its own.
func (h *OperatorPartialsHandler) ForceReleaseClaim(w http.ResponseWriter, r *http.Request) {
if h.Registry == nil {
h.renderDomains(w, r, http.StatusServiceUnavailable, "", "The domain registry isn't available.")
return
}
ctx := r.Context()
claimID := r.PathValue("claimID")
claim, err := h.Registry.ForceReleaseClaim(ctx, claimID)
switch {
case err == nil:
case errors.Is(err, domains.ErrNotFound):
h.renderDomains(w, r, http.StatusNotFound, "", "That claim no longer exists — the list below is current.")
return
case errors.Is(err, domains.ErrClaimNotReleasable):
h.renderDomains(w, r, http.StatusConflict, "",
"Operator roots come from deployment configuration and can't be released here.")
return
case errors.Is(err, domains.ErrClaimHasPlacements):
h.renderDomains(w, r, http.StatusConflict, "",
"That claim still has placements. Remove the sites on it first, then release it.")
return
case errors.Is(err, domains.ErrClaimNotActive):
h.renderDomains(w, r, http.StatusConflict, "",
"That claim reached a terminal state while the page was open — the list below is current.")
return
default:
h.Logger.Error("failed to force-release domain claim",
slog.String("claim_id", claimID), slog.Any("error", err))
h.renderDomains(w, r, http.StatusInternalServerError, "",
"Failed to release that claim. Details are in the server logs.")
return
}
if claim.Status == domains.StatusPending && h.TemporalClient != nil {
if cErr := h.TemporalClient.CancelWorkflow(ctx,
wfdomains.VerifyClaimWorkflowIDPrefix+claimID, ""); cErr != nil {
h.Logger.Warn("failed to cancel verification workflow after force-release",
slog.String("claim_id", claimID), slog.Any("error", cErr))
}
}
h.Logger.Info("operator force-released a domain claim",
slog.String("claim_id", claimID),
slog.String("root", claim.RootFqdn),
slog.String("was_status", claim.Status),
slog.String("workspace_id", claim.WorkspaceID))
h.renderDomains(w, r, http.StatusOK, claim.RootFqdn+" released", "")
}
// renderDomains re-renders the claims list into #operator-body at the given
// status: 200 with a toast on success, a 4xx/5xx with the banner otherwise
// (docs/operator-ux-conventions.md §8 — the status is what error-handler.js
// keys off, and a refused mutation must never answer 200).
func (h *OperatorPartialsHandler) renderDomains(w http.ResponseWriter, r *http.Request, status int, success, errMsg string) {
fireSuccessToast(w, success)
data := h.loadDomainsPageData(r.Context(), errMsg)
if status != http.StatusOK {
w.WriteHeader(status)
}
h.Templates.Render(w, "operator_domains.html", data)
}