259 lines
9.9 KiB
Go
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)
|
|
}
|