openova/core/marketplace-api/handlers/handlers.go
e3mrah e8603085a7
chore(canon): purge openova.io test-data string leaks (Refs #2025) (#2046)
Pillar-4 audit (/tmp/audit-pillar4-deep-wiring-2026-05-20.md) flagged 5 leak
sites where `openova.io` / `openova.cloud` / `sme.openova.io` test-data
strings violated the canonical-domain rule (CLAUDE.md §0: NEVER `openova.io`
in any test fixture, URL string, or test data).

Replacements use the canonical pool:
- Sovereign FQDN: `t39.omani.works`
- Tenant Org FQDN: `<slug>.omani.homes` (+ omani.rest / omani.trade / omani.works)

This sweep is EXAMPLE-STRING + TEST-FIXTURE-placeholder + DEFAULT-VALUE only.
Untouched: Kubernetes API groups (`sandbox.openova.io`, `apps.openova.io`,
`orgs.openova.io`), label keys (`openova.io/region`, `openova.io/managed-by`,
`openova.io/preview-pr`, etc.), Chart maintainer emails, marketing-site CORS
origin / from-email defaults — all real product identifiers, not test data.

Changes by file:
- products/sandbox/docs/user-journey.md: 8 example URLs in scene narratives
  (console.rzk7.openova.io → console.t39.omani.works; eventforge.sb-rzk7.
  openova.io → eventforge.sb-t39.omani.homes; etc.).
- products/sandbox/mcp-server/internal/tools/env.go: 1 docstring example
  (SANDBOX_SOVEREIGN_FQDN = "acme.openova.io" → "t39.omani.works").
- products/sandbox/mcp-server/internal/tools/sandbox_preview.go: 1 docstring
  example FQDN (pr-1483.eventforge.sb-emrah.acme.openova.io → ...omani.homes);
  preserved all `openova.io/preview-*` label-key constants.
- products/catalyst/chart/crds/sandbox.yaml: 1 previewDomain example
  (sb-emrah.rzk7.openova.io → sb-emrah.t39.omani.works); preserved
  `name: sandboxes.sandbox.openova.io` + `group: sandbox.openova.io` API group.
- products/sandbox/mcp-server/internal/tools/marketplace.go: 5 docstring
  example pool-zones (sme.openova.io / openova.cloud → omani.homes /
  omani.rest / omani.trade / omani.works per
  core/services/domain/store/store.go AllowedTLDs).
- products/sandbox/mcp-server/internal/tools/marketplace_test.go: 7 test
  fixture placeholders (openova.cloud → omani.homes; sme.openova.io →
  cname.t39.omani.works); test asserts forwarding behavior, domain is
  arbitrary.
- products/sandbox/mcp-server/internal/tools/registry.go: 1 SovereignFQDN
  field docstring example.
- core/marketplace-api/handlers/provisioner.go + handlers.go: 3 DEFAULT-VALUE
  hardcoded `.openova.cloud` suffixes in the marketing-site marketplace-api
  simulator (Subdomain + ".openova.cloud" → ".omani.homes" in tenant CNAME
  output + simulated provision response).

Validation:
- go build ./... + go test ./... from products/sandbox/mcp-server: PASS
- go build ./... + go test ./... from core/marketplace-api: PASS
  (handlers/provisioner have no test files; simulator code).
- yaml.safe_load on products/catalyst/chart/crds/sandbox.yaml: PASS.
- No chart bump: no values.yaml default touched, no template touched.
- No bootstrap-kit pin lockstep needed.

Acceptance per audit TBD-V25:
- grep -rn "openova.io" in touched files returns only API-group / label-key
  lines (production-config, per task spec do-not-touch list).

Refs #2025

Co-authored-by: Hatice Yildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:52:11 +04:00

489 lines
13 KiB
Go

package handlers
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/openova-io/openova-private/website/marketplace-api/store"
)
type Handler struct {
Store *store.MemoryStore
JWTSecret []byte
AllowOrigin string
GitRepoPath string
SMTPHost string
SMTPPort string
FromEmail string
}
// CORS wraps a handler with CORS headers.
func (h *Handler) CORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", h.AllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v any) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) {
h.writeJSON(w, status, map[string]string{"error": msg})
}
// generateJWT creates a tenant JWT token.
func (h *Handler) generateJWT(tenantID, email string) (string, error) {
claims := jwt.MapClaims{
"sub": tenantID,
"email": email,
"iat": time.Now().Unix(),
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(h.JWTSecret)
}
// authenticateTenant extracts and validates the tenant JWT.
func (h *Handler) authenticateTenant(r *http.Request) (string, error) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return "", jwt.ErrTokenMalformed
}
tokenStr := strings.TrimPrefix(auth, "Bearer ")
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) {
return h.JWTSecret, nil
})
if err != nil {
return "", err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return "", jwt.ErrTokenInvalidClaims
}
sub, _ := claims["sub"].(string)
return sub, nil
}
// --- Catalog Endpoints ---
func (h *Handler) ListApps(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
h.writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "message": "catalog served from static site"})
}
func (h *Handler) GetApp(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
slug := strings.TrimPrefix(r.URL.Path, "/api/marketplace/apps/")
if slug == "" {
h.writeError(w, http.StatusBadRequest, "slug required")
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
h.writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "message": "catalog served from static site"})
}
func (h *Handler) ListBundles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
w.Header().Set("Cache-Control", "public, max-age=3600")
h.writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "message": "bundles served from static site"})
}
// --- Provisioning Endpoints ---
type ProvisionRequest struct {
CompanyName string `json:"companyName"`
Email string `json:"email"`
Subdomain string `json:"subdomain"`
Size string `json:"size"`
Apps []string `json:"apps"`
AddOns []string `json:"addOns"`
Config map[string]string `json:"config"`
}
func (h *Handler) CreateProvision(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req ProvisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.CompanyName == "" || req.Email == "" || req.Subdomain == "" || req.Size == "" || len(req.Apps) == 0 {
h.writeError(w, http.StatusBadRequest, "companyName, email, subdomain, size, and apps are required")
return
}
provisionID := uuid.New().String()
tenantID := uuid.New().String()
now := time.Now()
// Build provisioning steps
steps := []store.ProvisionStep{
{Name: "Creating virtual cluster", Status: store.StatusPending},
{Name: "Configuring networking", Status: store.StatusPending},
}
for _, app := range req.Apps {
steps = append(steps, store.ProvisionStep{
Name: "Deploying " + app,
Status: store.StatusPending,
})
}
steps = append(steps,
store.ProvisionStep{Name: "Configuring TLS certificates", Status: store.StatusPending},
store.ProvisionStep{Name: "Running health checks", Status: store.StatusPending},
)
// Generate JWT for the new tenant
token, err := h.generateJWT(tenantID, req.Email)
if err != nil {
log.Printf("JWT generation error: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
provision := &store.Provision{
ID: provisionID,
TenantID: tenantID,
CompanyName: req.CompanyName,
Email: req.Email,
Subdomain: req.Subdomain,
Size: req.Size,
Apps: req.Apps,
AddOns: req.AddOns,
Status: store.StatusProvisioning,
Steps: steps,
CreatedAt: now,
UpdatedAt: now,
JWTToken: token,
}
h.Store.CreateProvision(provision)
// Start async provisioning
go h.runProvisioning(provision)
log.Printf("Provision started: id=%s tenant=%s apps=%v size=%s", provisionID, tenantID, req.Apps, req.Size)
h.writeJSON(w, http.StatusCreated, map[string]string{
"provisionId": provisionID,
"tenantId": tenantID,
"status": string(store.StatusProvisioning),
"token": token,
})
}
func (h *Handler) GetProvisionStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/marketplace/provisions/")
parts := strings.SplitN(id, "/", 2)
provisionID := parts[0]
if provisionID == "" {
h.writeError(w, http.StatusBadRequest, "provision ID required")
return
}
provision := h.Store.GetProvision(provisionID)
if provision == nil {
h.writeError(w, http.StatusNotFound, "provision not found")
return
}
// Calculate progress percentage
total := len(provision.Steps)
done := 0
for _, step := range provision.Steps {
if step.Status == store.StatusCompleted {
done++
}
}
pct := 0
if total > 0 {
pct = (done * 100) / total
}
h.writeJSON(w, http.StatusOK, map[string]any{
"id": provision.ID,
"tenantId": provision.TenantID,
"status": provision.Status,
"steps": provision.Steps,
"progress": pct,
})
}
// --- Tenant Endpoints ---
func (h *Handler) TenantRouter(w http.ResponseWriter, r *http.Request) {
// Extract tenant ID and sub-path from URL
path := strings.TrimPrefix(r.URL.Path, "/api/marketplace/tenants/")
parts := strings.SplitN(path, "/", 2)
tenantID := parts[0]
if tenantID == "" {
h.writeError(w, http.StatusBadRequest, "tenant ID required")
return
}
// Authenticate
authTenantID, err := h.authenticateTenant(r)
if err != nil {
h.writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if authTenantID != tenantID {
h.writeError(w, http.StatusForbidden, "forbidden")
return
}
subPath := ""
if len(parts) > 1 {
subPath = parts[1]
}
switch {
case subPath == "" && r.Method == http.MethodGet:
h.GetTenant(w, r, tenantID)
case subPath == "" && r.Method == http.MethodDelete:
h.DeleteTenant(w, r, tenantID)
case subPath == "scale" && r.Method == http.MethodPost:
h.ScaleTenant(w, r, tenantID)
case subPath == "suspend" && r.Method == http.MethodPost:
h.SuspendTenant(w, r, tenantID)
case subPath == "resume" && r.Method == http.MethodPost:
h.ResumeTenant(w, r, tenantID)
case subPath == "backup" && r.Method == http.MethodPost:
h.BackupTenant(w, r, tenantID)
case subPath == "apps" && r.Method == http.MethodPost:
h.AddApp(w, r, tenantID)
case subPath == "domains" && r.Method == http.MethodPost:
h.AddDomain(w, r, tenantID)
case strings.HasPrefix(subPath, "apps/") && strings.HasSuffix(subPath, "/restart") && r.Method == http.MethodPost:
appSlug := strings.TrimPrefix(subPath, "apps/")
appSlug = strings.TrimSuffix(appSlug, "/restart")
h.RestartApp(w, r, tenantID, appSlug)
default:
h.writeError(w, http.StatusNotFound, "not found")
}
}
func (h *Handler) GetTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
h.writeJSON(w, http.StatusOK, tenant)
}
func (h *Handler) DeleteTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
// In production: commit deletion to Git, Flux removes vCluster
log.Printf("Tenant deletion requested: %s", tenantID)
tenant.VClusterStatus = "deleting"
h.Store.UpdateTenant(tenant)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleting"})
}
type ScaleRequest struct {
Size string `json:"size"`
}
func (h *Handler) ScaleTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
var req ScaleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
validSizes := map[string]string{"xs": "XS", "s": "S", "m": "M", "l": "L"}
label, ok := validSizes[req.Size]
if !ok {
h.writeError(w, http.StatusBadRequest, "invalid size: must be xs, s, m, or l")
return
}
log.Printf("Tenant %s scaling from %s to %s", tenantID, tenant.Size, req.Size)
tenant.Size = req.Size
tenant.SizeLabel = label
h.Store.UpdateTenant(tenant)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "scaling", "size": req.Size})
}
func (h *Handler) SuspendTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
tenant.VClusterStatus = "suspended"
h.Store.UpdateTenant(tenant)
log.Printf("Tenant %s suspended", tenantID)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "suspended"})
}
func (h *Handler) ResumeTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
tenant.VClusterStatus = "running"
h.Store.UpdateTenant(tenant)
log.Printf("Tenant %s resumed", tenantID)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "running"})
}
func (h *Handler) BackupTenant(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
log.Printf("Backup triggered for tenant %s", tenantID)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "backup_started"})
}
type AddAppRequest struct {
Slug string `json:"slug"`
}
func (h *Handler) AddApp(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
var req AddAppRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
// Check if app already deployed
for _, app := range tenant.Apps {
if app.Slug == req.Slug {
h.writeError(w, http.StatusConflict, "app already deployed")
return
}
}
log.Printf("Adding app %s to tenant %s", req.Slug, tenantID)
h.writeJSON(w, http.StatusAccepted, map[string]string{"status": "deploying", "app": req.Slug})
}
type AddDomainRequest struct {
Domain string `json:"domain"`
}
func (h *Handler) AddDomain(w http.ResponseWriter, r *http.Request, tenantID string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
var req AddDomainRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Domain == "" {
h.writeError(w, http.StatusBadRequest, "domain required")
return
}
log.Printf("Adding domain %s to tenant %s", req.Domain, tenantID)
h.writeJSON(w, http.StatusAccepted, map[string]string{
"status": "configuring",
"domain": req.Domain,
"cname": tenant.Subdomain + ".omani.homes",
})
}
func (h *Handler) RestartApp(w http.ResponseWriter, r *http.Request, tenantID, appSlug string) {
tenant := h.Store.GetTenant(tenantID)
if tenant == nil {
h.writeError(w, http.StatusNotFound, "tenant not found")
return
}
found := false
for _, app := range tenant.Apps {
if app.Slug == appSlug {
found = true
break
}
}
if !found {
h.writeError(w, http.StatusNotFound, "app not found")
return
}
log.Printf("Restarting app %s for tenant %s", appSlug, tenantID)
h.writeJSON(w, http.StatusOK, map[string]string{"status": "restarting", "app": appSlug})
}