diff --git a/core/controllers/application/internal/controller/application_controller.go b/core/controllers/application/internal/controller/application_controller.go index 4558325b..7de05c25 100644 --- a/core/controllers/application/internal/controller/application_controller.go +++ b/core/controllers/application/internal/controller/application_controller.go @@ -59,11 +59,11 @@ import ( "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" - "github.com/openova-io/openova/core/controllers/application/internal/validate" - "github.com/openova-io/openova/core/controllers/pkg/gitea" "github.com/openova-io/openova/core/controllers/internal/placement" - "github.com/openova-io/openova/core/controllers/internal/render" "github.com/openova-io/openova/core/controllers/internal/semver" + "github.com/openova-io/openova/core/controllers/pkg/gitea" + "github.com/openova-io/openova/core/controllers/pkg/render" + "github.com/openova-io/openova/core/controllers/pkg/validate" ) // GVR pins for the three CRDs the controller reads. Storage versions diff --git a/core/controllers/internal/render/manifests.go b/core/controllers/pkg/render/manifests.go similarity index 100% rename from core/controllers/internal/render/manifests.go rename to core/controllers/pkg/render/manifests.go diff --git a/core/controllers/internal/render/manifests_test.go b/core/controllers/pkg/render/manifests_test.go similarity index 100% rename from core/controllers/internal/render/manifests_test.go rename to core/controllers/pkg/render/manifests_test.go diff --git a/core/controllers/application/internal/validate/parameters.go b/core/controllers/pkg/validate/parameters.go similarity index 100% rename from core/controllers/application/internal/validate/parameters.go rename to core/controllers/pkg/validate/parameters.go diff --git a/core/controllers/application/internal/validate/parameters_test.go b/core/controllers/pkg/validate/parameters_test.go similarity index 100% rename from core/controllers/application/internal/validate/parameters_test.go rename to core/controllers/pkg/validate/parameters_test.go diff --git a/products/catalyst/bootstrap/api/cmd/api/main.go b/products/catalyst/bootstrap/api/cmd/api/main.go index 2dcaf2e1..b7c72d90 100644 --- a/products/catalyst/bootstrap/api/cmd/api/main.go +++ b/products/catalyst/bootstrap/api/cmd/api/main.go @@ -247,6 +247,19 @@ func main() { } } + // EPIC-2 #1097 slice I — live install flow. The catalog client is + // the proxy hop catalyst-api uses to fetch Blueprints from + // catalyst-catalog (slice L, #1148). Per + // docs/INVIOLABLE-PRINCIPLES.md #4 the URL is env-overridable + // (CATALYST_CATALOG_URL); the in-cluster service FQDN is the + // production default. Wiring here is unconditional — when the + // catalog upstream is down the handlers surface 502/503 with a + // clear "catalog upstream" detail rather than 500. + h.SetCatalogClient(handler.NewCatalogClientFromEnv()) + log.Info("catalog: client wired", + "url", env("CATALYST_CATALOG_URL", "http://catalyst-catalog.openova-system.svc.cluster.local:8080"), + ) + // /healthz is LIVENESS — always 200 if the process is up and the // HTTP server is serving. /readyz is READINESS — 200 only when // the primary Sovereign's informers are synced (or no Sovereigns @@ -723,6 +736,22 @@ func main() { rg.Post("/api/v1/sovereigns/{id}/rbac/assign", h.HandleRBACAssign) rg.Get("/api/v1/sovereigns/{id}/rbac/access-matrix", h.HandleRBACAccessMatrix) + // EPIC-2 (#1097) slice I — live install flow. Operators submit + // Application install requests here; the handler validates + // parameters against Blueprint.spec.configSchema (via the + // promoted core/controllers/pkg/validate package) and creates + // the Application CR per ADR-0001 §2.7. The application- + // controller (slice C4 #1133) reconciles the rest. The + // preview endpoint runs the SAME renderer the controller uses + // (core/controllers/pkg/render, promoted in this slice) so a + // "looks-good in preview" cannot diverge from the actual + // install. Both endpoints require tier-admin or higher per + // docs/INVIOLABLE-PRINCIPLES.md #5. + rg.Post("/api/v1/sovereigns/{id}/applications", h.HandleApplicationInstall) + rg.Post("/api/v1/sovereigns/{id}/applications/preview", h.HandleApplicationPreview) + rg.Get("/api/v1/sovereigns/{id}/applications/{name}/status", h.HandleApplicationStatus) + rg.Get("/api/v1/sovereigns/{id}/applications/{name}/stream", h.HandleApplicationStream) + // SME-tier user CRUD + role mapping (issue #802, ADR-0003). // Owned by the unified-rbac slice of catalyst-api. Tenant // scoping is by X-Tenant-Host header (sent by the SPA from diff --git a/products/catalyst/bootstrap/api/go.mod b/products/catalyst/bootstrap/api/go.mod index 24d254ad..f897003d 100644 --- a/products/catalyst/bootstrap/api/go.mod +++ b/products/catalyst/bootstrap/api/go.mod @@ -5,11 +5,26 @@ go 1.26.0 require ( github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/minio/minio-go/v7 v7.0.91 + github.com/openova-io/openova/core/controllers v0.0.0-00010101000000-000000000000 + github.com/prometheus/client_golang v1.23.2 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 + sigs.k8s.io/yaml v1.6.0 ) +// EPIC-2 Slice I (#1097): the install + preview handlers depend on +// the promoted core/controllers/pkg/{render,validate} packages. The +// replace pins to the in-tree controllers module so the catalyst-api +// build sees the same version of the renderer that ships in +// application-controller's binary — preview-vs-controller drift is +// impossible to ship. +replace github.com/openova-io/openova/core/controllers => ../../../../core/controllers + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -23,9 +38,7 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -33,16 +46,15 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/minio/crc64nvme v1.0.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.0.91 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect @@ -57,12 +69,10 @@ require ( google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/products/catalyst/bootstrap/api/go.sum b/products/catalyst/bootstrap/api/go.sum index c5e28109..766bbe20 100644 --- a/products/catalyst/bootstrap/api/go.sum +++ b/products/catalyst/bootstrap/api/go.sum @@ -56,6 +56,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= @@ -87,6 +89,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/products/catalyst/bootstrap/api/internal/handler/applications.go b/products/catalyst/bootstrap/api/internal/handler/applications.go new file mode 100644 index 00000000..74842789 --- /dev/null +++ b/products/catalyst/bootstrap/api/internal/handler/applications.go @@ -0,0 +1,645 @@ +// Package handler — applications.go: EPIC-2 Slice I (#1097) live +// install flow. +// +// REST surface: +// +// POST /api/v1/sovereigns/{id}/applications — install (creates Application CR) +// GET /api/v1/sovereigns/{id}/applications/{name}/status — rolled-up status snapshot +// GET /api/v1/sovereigns/{id}/applications/{name}/stream — SSE live status updates +// +// Body shape of the install POST: +// +// { +// "blueprintRef": { "name": "bp-wordpress", "version": "1.2.3" }, +// "name": "wp-prod", +// "organizationRef":"acme", +// "environmentRef": "acme-prod", +// "parameters": { "domain": "shop.acme.com", ... }, +// "placement": { "mode": "single-region", "regions": ["hz-fsn-rtz-prod"] } +// } +// +// Behavior contract: +// +// 201 Created — Application CR successfully created. Body returns +// { name, namespace, status: { phase, ... } }. +// 400 — invalid body, missing required field, parameters fail +// JSON-Schema validation against Blueprint.spec.configSchema. +// 403 — caller lacks tier-admin or higher on the target Environment. +// 404 — Sovereign deployment unknown OR Blueprint not found +// in catalyst-catalog. +// 409 — Application with the same metadata.name already exists +// in the target namespace. +// 503 — catalog client unwired or Sovereign cluster unreachable. +// +// Architecture rules: +// +// - ADR-0001 §2.7: the Application CR is the source of truth. The +// handler creates the CR and returns; the application-controller +// (slice C4 #1133) reconciles it into a per-Org Gitea repo + Flux +// HelmRelease per region. NO bypass. +// - INVIOLABLE-PRINCIPLES.md #1 (target-state shape first time): the +// install creates a real CR with full spec, never a stub. +// - INVIOLABLE-PRINCIPLES.md #4 (never hardcode): every URL is +// env-derived; the catalog upstream lives in catalogClient. +// - INVIOLABLE-PRINCIPLES.md #5 (least privilege): the install +// handler enforces tier-admin or higher. The same authorization +// shape as slice X #1147 (policy_mode.go) and slice A #1143 +// (rbac_assign.go). +// +// The promoted core/controllers/pkg/validate package validates the +// caller's parameters against Blueprint.spec.configSchema using the +// canonical santhosh-tekuri/jsonschema v5 library — same code the +// application-controller runs at admission time, so a 400 here +// guarantees the controller will accept the CR. +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/openova-io/openova/core/controllers/pkg/validate" + "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/auth" +) + +// SSE timing knobs — keep responsive without hammering the apiserver. +// Per docs/INVIOLABLE-PRINCIPLES.md #4 these can be lifted to env vars +// when a real ops scenario justifies it; for slice I the defaults are +// adequate. +const ( + applicationStreamPingInterval = 15 * time.Second + applicationStreamPollInterval = 2 * time.Second +) + +// timeNewTicker is a tiny indirection so tests can swap the ticker +// source if they want millisecond-cadence pulses without changing the +// production constants. +var timeNewTicker = time.NewTicker + +// ApplicationGVR — the Namespaced Application CRD shipped at +// products/catalyst/chart/crds/application.yaml. +func ApplicationGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: "apps.openova.io", + Version: "v1", + Resource: "applications", + } +} + +// ── Wire shapes ────────────────────────────────────────────────────── + +// applicationBlueprintRef mirrors `Application.spec.blueprintRef`. +type applicationBlueprintRef struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// applicationPlacement mirrors `Application.spec.placement` + regions[]. +type applicationPlacement struct { + Mode string `json:"mode"` + Regions []string `json:"regions"` +} + +// applicationInstallRequest is the body of POST +// /api/v1/sovereigns/{id}/applications. +type applicationInstallRequest struct { + BlueprintRef applicationBlueprintRef `json:"blueprintRef"` + Name string `json:"name"` + OrganizationRef string `json:"organizationRef"` + EnvironmentRef string `json:"environmentRef"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Placement applicationPlacement `json:"placement"` +} + +// applicationInstallResponse is the body returned on 201. +type applicationInstallResponse struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + UID string `json:"uid"` + Status map[string]interface{} `json:"status,omitempty"` +} + +// applicationStatusResponse is the body returned by GET .../status. +type applicationStatusResponse struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Phase string `json:"phase,omitempty"` + Status map[string]interface{} `json:"status,omitempty"` +} + +// ── HTTP handler — install ─────────────────────────────────────────── + +// HandleApplicationInstall — POST /api/v1/sovereigns/{id}/applications +// +// See file-level doc for the full contract. +func (h *Handler) HandleApplicationInstall(w http.ResponseWriter, r *http.Request) { + depID := chi.URLParam(r, "id") + dep, ok := h.lookupDeploymentForInfra(depID) + if !ok { + writeNotFound(w, depID) + return + } + + if h.catalogClient == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "catalog-not-wired", + "detail": "catalog client unconfigured (CATALYST_CATALOG_URL); install requires the catalog upstream", + }) + return + } + + var body applicationInstallRequest + if !decodeMutationBody(w, r, &body) { + return + } + if msg, ok := validateApplicationInstallRequest(body); !ok { + writeBadRequest(w, "invalid-application-install", msg) + return + } + + // Authorization: tier-admin or higher (same shape as policy_mode.go, + // rbac_assign.go). Nil-claims path through; the auth middleware is + // the single source of truth for whether auth was required. + if claims := auth.ClaimsFromContext(r.Context()); claims != nil { + if !applicationInstallCallerAuthorized(claims) { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "forbidden", + "detail": "POST /applications requires tier-admin or higher on the target Environment", + }) + return + } + } + + // 1. Fetch the Blueprint at the requested version. The catalog + // populates `raw` on the version-pinned endpoint so we can + // validate parameters against `spec.configSchema` without a + // second round-trip. + bp, err := h.catalogClient.GetVersion( + r.Context(), + body.BlueprintRef.Name, + body.BlueprintRef.Version, + applicationSessionToken(r), + ) + if err != nil { + if errors.Is(err, ErrBlueprintNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "blueprint-not-found", + "detail": fmt.Sprintf("blueprint %s@%s is not in the catalog", body.BlueprintRef.Name, body.BlueprintRef.Version), + }) + return + } + h.log.Warn("application install: catalog fetch failed", + "depId", depID, "blueprint", body.BlueprintRef.Name, "version", body.BlueprintRef.Version, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "catalog-upstream", + "detail": err.Error(), + }) + return + } + + // 2. Validate user parameters against Blueprint.spec.configSchema. + // Same code the application-controller runs at admission, so a + // 400 here mirrors the eventual reconcile rejection. + configSchema := blueprintConfigSchema(bp) + rep, vErr := validate.Parameters(configSchema, body.Parameters) + if vErr != nil { + // Internal error compiling the schema — Blueprint itself is + // bugged. Surface as 502 so the operator sees "blueprint + // problem", not "your input was wrong". + h.log.Warn("application install: validate compile failed", + "depId", depID, "blueprint", body.BlueprintRef.Name, "err", vErr) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "blueprint-schema-malformed", + "detail": vErr.Error(), + }) + return + } + if !rep.Valid { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "invalid-parameters", + "detail": "parameters do not satisfy Blueprint.spec.configSchema", + "errors": rep.Errors, + "blueprint": map[string]string{ + "name": body.BlueprintRef.Name, + "version": body.BlueprintRef.Version, + }, + }) + return + } + + // 3. Create the Application CR. Per ADR-0001 §2.7 the CR is the + // source of truth — the controller (slice C4 #1133) reconciles + // everything else (Gitea repo, HelmRelease per region, status + // rollup). + client, err := h.sovereignDynamicClient(dep) + if err != nil { + writeUserAccessUnavailable(w, err) + return + } + + obj := newApplicationUnstructured(body) + created, err := client.Resource(ApplicationGVR()).Namespace(body.OrganizationRef).Create( + r.Context(), obj, metav1.CreateOptions{}) + if err != nil { + if apierrors.IsAlreadyExists(err) { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "application-exists", + "detail": fmt.Sprintf("Application %q already exists in namespace %q", body.Name, body.OrganizationRef), + }) + return + } + h.log.Warn("application install: create CR failed", + "depId", depID, "name", body.Name, "ns", body.OrganizationRef, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "application-create-failed", + "detail": err.Error(), + }) + return + } + + // 4. Return 201 with the initial status (almost certainly empty — + // the controller hasn't run yet — but the field exists so the + // UI can wire its status modal without a follow-up GET). + resp := applicationInstallResponse{ + Name: created.GetName(), + Namespace: created.GetNamespace(), + UID: string(created.GetUID()), + } + if statusObj, ok, _ := unstructured.NestedMap(created.Object, "status"); ok { + resp.Status = statusObj + } + writeJSON(w, http.StatusCreated, resp) +} + +// ── HTTP handler — status snapshot ─────────────────────────────────── + +// HandleApplicationStatus — GET +// /api/v1/sovereigns/{id}/applications/{name}/status +// +// Returns the rolled-up Application CR status. The optional +// `?namespace=` query selects the Org namespace; when absent the +// handler returns the first Application CR named `name` across all +// namespaces (typical case: one Org per Sovereign in EPIC-2). +func (h *Handler) HandleApplicationStatus(w http.ResponseWriter, r *http.Request) { + depID := chi.URLParam(r, "id") + name := chi.URLParam(r, "name") + if name == "" { + writeBadRequest(w, "missing-name", "application name is required") + return + } + + dep, ok := h.lookupDeploymentForInfra(depID) + if !ok { + writeNotFound(w, depID) + return + } + client, err := h.sovereignDynamicClient(dep) + if err != nil { + writeUserAccessUnavailable(w, err) + return + } + + ns := strings.TrimSpace(r.URL.Query().Get("namespace")) + obj, getErr := getApplicationCR(r.Context(), client, name, ns) + if getErr != nil { + if apierrors.IsNotFound(getErr) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "application-not-found", + "detail": fmt.Sprintf("Application %q not found", name), + }) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "application-get-failed", + "detail": getErr.Error(), + }) + return + } + + resp := applicationStatusResponse{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + if phase, ok, _ := unstructured.NestedString(obj.Object, "status", "phase"); ok { + resp.Phase = phase + } + if statusObj, ok, _ := unstructured.NestedMap(obj.Object, "status"); ok { + resp.Status = statusObj + } + writeJSON(w, http.StatusOK, resp) +} + +// ── HTTP handler — SSE status stream ──────────────────────────────── + +// HandleApplicationStream — GET +// /api/v1/sovereigns/{id}/applications/{name}/stream (SSE) +// +// Per the brief this reuses internal/k8scache/factory.go's SSE fanout +// for live status pushes. Implementation here is a simple poll-and-push +// loop bound to the request context: every 2s, GET the Application CR, +// emit `data: \n\n` if `status.phase` changed, plus a +// keepalive ping every 15s. When the cache factory becomes available +// for cross-cluster Application GVR informers in a follow-up slice, +// this can be swapped for a Subscribe call without changing the wire +// shape. +func (h *Handler) HandleApplicationStream(w http.ResponseWriter, r *http.Request) { + depID := chi.URLParam(r, "id") + name := chi.URLParam(r, "name") + if name == "" { + http.Error(w, "missing application name", http.StatusBadRequest) + return + } + dep, ok := h.lookupDeploymentForInfra(depID) + if !ok { + http.Error(w, "deployment not found", http.StatusNotFound) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + client, err := h.sovereignDynamicClient(dep) + if err != nil { + writeUserAccessUnavailable(w, err) + return + } + + ns := strings.TrimSpace(r.URL.Query().Get("namespace")) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + _, _ = fmt.Fprintf(w, ": connected app=%s\n\n", name) + flusher.Flush() + + enc := json.NewEncoder(w) + pingT := timeNewTicker(applicationStreamPingInterval) + defer pingT.Stop() + pollT := timeNewTicker(applicationStreamPollInterval) + defer pollT.Stop() + + var lastPhase string + emit := func() { + obj, err := getApplicationCR(r.Context(), client, name, ns) + if err != nil { + return + } + phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") + if phase == lastPhase { + return + } + lastPhase = phase + statusObj, _, _ := unstructured.NestedMap(obj.Object, "status") + _, _ = w.Write([]byte("data: ")) + _ = enc.Encode(map[string]interface{}{ + "name": obj.GetName(), + "namespace": obj.GetNamespace(), + "phase": phase, + "status": statusObj, + }) + _, _ = w.Write([]byte("\n")) + flusher.Flush() + } + + // Emit initial state immediately so subscribers see today's snapshot + // without waiting for the first poll tick. + emit() + for { + select { + case <-r.Context().Done(): + return + case <-pingT.C: + if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { + return + } + flusher.Flush() + case <-pollT.C: + emit() + } + } +} + +// ── Validation + authorization ─────────────────────────────────────── + +// applicationNamePattern enforces the K8s metadata.name shape. RFC 1123 +// label rules are stricter than the CRD's regex; we keep the conservative +// subset so a client posting weird names hits a 400 here, not a 422 from +// the apiserver. +func validateApplicationInstallRequest(req applicationInstallRequest) (string, bool) { + if strings.TrimSpace(req.Name) == "" { + return "name is required", false + } + if !isValidK8sName(req.Name) { + return "name must be a valid K8s name (RFC 1123 lowercase alphanumeric + hyphens, 1-63 chars)", false + } + if strings.TrimSpace(req.OrganizationRef) == "" { + return "organizationRef is required", false + } + if !isValidK8sName(req.OrganizationRef) { + return "organizationRef must be a valid K8s name", false + } + if strings.TrimSpace(req.EnvironmentRef) == "" { + return "environmentRef is required", false + } + if strings.TrimSpace(req.BlueprintRef.Name) == "" { + return "blueprintRef.name is required", false + } + if !strings.HasPrefix(req.BlueprintRef.Name, "bp-") { + return "blueprintRef.name must be of the form bp-", false + } + if strings.TrimSpace(req.BlueprintRef.Version) == "" { + return "blueprintRef.version is required", false + } + if strings.TrimSpace(req.Placement.Mode) == "" { + return "placement.mode is required", false + } + switch req.Placement.Mode { + case "single-region", "active-active", "active-hotstandby": + default: + return "placement.mode must be one of single-region, active-active, active-hotstandby", false + } + if len(req.Placement.Regions) == 0 { + return "placement.regions must list at least one region", false + } + if len(req.Placement.Regions) > 5 { + return "placement.regions cannot exceed 5 entries", false + } + for i, r := range req.Placement.Regions { + if strings.TrimSpace(r) == "" { + return fmt.Sprintf("placement.regions[%d] is empty", i), false + } + } + return "", true +} + +// applicationInstallCallerAuthorized — same authorization shape as +// rbacAssignCallerAuthorized + policyModeCallerAuthorized: realm-role +// check OR custom `tier` claim. Conservative-by-default: any +// unrecognised claim shape rejects. +// +// The brief calls for "tier-admin or higher on the target Environment" +// — the per-Environment scope check is left for a future Manara +// integration; for slice I we accept any of the privileged realm roles +// + the admin/owner tier claim, which is the same surface +// /rbac/assign and /environments/{env}/policy use today. A follow-up +// slice can layer Environment-scoped boundaries via the same +// applicationInstallCallerAuthorized seam. +func applicationInstallCallerAuthorized(claims *auth.Claims) bool { + if claims == nil { + return false + } + for _, want := range rbacAssignPrivilegedRoles { + if claims.HasRealmRole(want) { + return true + } + } + switch strings.ToLower(strings.TrimSpace(claims.Tier)) { + case "admin", "owner": + return true + } + return false +} + +// ── Helpers ────────────────────────────────────────────────────────── + +// newApplicationUnstructured composes the Application CR per the +// install request. Sets the spec to mirror the API surface exactly so a +// downstream Get returns the same shape the caller posted (modulo +// metadata + status). +func newApplicationUnstructured(req applicationInstallRequest) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetAPIVersion(ApplicationGVR().Group + "/" + ApplicationGVR().Version) + obj.SetKind("Application") + obj.SetName(req.Name) + obj.SetNamespace(req.OrganizationRef) + obj.SetLabels(map[string]string{ + "catalyst.openova.io/managed-by": "catalyst-api", + "catalyst.openova.io/organization": req.OrganizationRef, + "catalyst.openova.io/environment": req.EnvironmentRef, + "catalyst.openova.io/blueprint": req.BlueprintRef.Name, + "catalyst.openova.io/blueprint-version": req.BlueprintRef.Version, + }) + + regions := make([]any, 0, len(req.Placement.Regions)) + for _, r := range req.Placement.Regions { + regions = append(regions, r) + } + spec := map[string]any{ + "environmentRef": req.EnvironmentRef, + "blueprintRef": map[string]any{ + "name": req.BlueprintRef.Name, + "version": req.BlueprintRef.Version, + }, + "placement": req.Placement.Mode, + "regions": regions, + } + if len(req.Parameters) > 0 { + // Map the user-passed JSON-shaped parameters straight in. The + // CRD's `x-kubernetes-preserve-unknown-fields` makes any tree + // valid; the controller's admission webhook + this handler's + // validate.Parameters call have already gated against + // configSchema. + paramsCopy := make(map[string]any, len(req.Parameters)) + for k, v := range req.Parameters { + paramsCopy[k] = v + } + spec["parameters"] = paramsCopy + } + _ = unstructured.SetNestedMap(obj.Object, spec, "spec") + return obj +} + +// getApplicationCR returns the Application CR matching `name`. If `ns` +// is empty, the handler falls back to a list across all namespaces and +// returns the first match — useful for the typical chroot case where +// the operator hits the URL without knowing the org's namespace. +func getApplicationCR( + ctx context.Context, + client dynamic.Interface, + name, ns string, +) (*unstructured.Unstructured, error) { + if ns != "" { + return client.Resource(ApplicationGVR()).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + } + list, err := client.Resource(ApplicationGVR()).Namespace("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + for i := range list.Items { + if list.Items[i].GetName() == name { + out := list.Items[i].DeepCopy() + return out, nil + } + } + return nil, apierrors.NewNotFound( + schema.GroupResource{Group: ApplicationGVR().Group, Resource: ApplicationGVR().Resource}, + name, + ) +} + +// blueprintConfigSchema — extracts `spec.configSchema` from the +// upstream Blueprint's `Raw` field. Returns nil when the Blueprint +// declares no configSchema (empty schema = no constraints, per +// validate.Parameters' contract). +func blueprintConfigSchema(bp *CatalogBlueprint) interface{} { + if bp == nil || bp.Raw == nil { + return nil + } + spec, ok := bp.Raw["spec"].(map[string]interface{}) + if !ok { + return nil + } + return spec["configSchema"] +} + +// applicationSessionToken extracts the catalyst-api session token from +// the request so the proxy hop to catalyst-catalog carries the same +// caller identity. We accept either the Authorization header or the +// session cookie; precedence: Authorization > Cookie. +// +// Per docs/INVIOLABLE-PRINCIPLES.md #10 the token never reaches the +// terminal: this function does NOT log the value. +func applicationSessionToken(r *http.Request) string { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } + if c, err := r.Cookie("catalyst_session"); err == nil && c != nil { + return c.Value + } + return "" +} + +// isValidK8sName checks the RFC 1123 label rules: 1..63 chars, +// lowercase letters + digits + hyphens, no leading/trailing hyphen. +func isValidK8sName(s string) bool { + if len(s) < 1 || len(s) > 63 { + return false + } + for i, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-': + if i == 0 || i == len(s)-1 { + return false + } + default: + return false + } + } + return true +} diff --git a/products/catalyst/bootstrap/api/internal/handler/applications_preview.go b/products/catalyst/bootstrap/api/internal/handler/applications_preview.go new file mode 100644 index 00000000..c4484ab3 --- /dev/null +++ b/products/catalyst/bootstrap/api/internal/handler/applications_preview.go @@ -0,0 +1,372 @@ +// Package handler — applications_preview.go: EPIC-2 Slice I (#1097) +// preview-before-install endpoint. +// +// REST surface: +// +// POST /api/v1/sovereigns/{id}/applications/preview +// +// Body shape (mirrors the install POST except no `name` is required — +// preview is a what-would-happen call): +// +// { +// "blueprintRef": { "name": "bp-wordpress", "version": "1.2.3" }, +// "name": "wp-prod", // optional; defaults to "-preview" +// "organizationRef":"acme", +// "environmentRef": "acme-prod", +// "parameters": { "domain": "shop.acme.com", ... }, +// "placement": { "mode": "single-region", "regions": ["hz-fsn-rtz-prod"] } +// } +// +// Response shape (consumed by I2's "Preview" modal AND, per the brief, +// EPIC-2 T's topology editor for "preview before topology change"): +// +// { +// "manifests": [ +// { "path": "clusters//applications//kustomization.yaml", +// "content": "..." }, +// { "path": "clusters//applications//helmrelease.yaml", +// "content": "..." } +// ], +// "diff": "", +// "blueprint": { "name": "bp-wordpress", "version": "1.2.3" }, +// "warnings": ["..."] // empty when nothing was flagged +// } +// +// Behavior contract: +// +// 200 OK — preview rendered. Manifests + diff returned. +// 400 — invalid body, parameters fail JSON-Schema validation. +// 403 — caller lacks tier-admin or higher. +// 404 — Sovereign or Blueprint unknown. +// 502 — catalog upstream error or render failure (Blueprint bug). +// 503 — catalog client unwired. +// +// Architecture rules: +// +// - Per ADR-0001 §2.7 the preview is read-only — no Application CR +// is created and no Gitea write happens. The endpoint is a pure +// simulation that runs the same renderer the application-controller +// uses (core/controllers/pkg/render, promoted from internal/ in +// this slice) so a "looks-good in preview" never diverges from the +// "actually installed" outcome. +// - Per INVIOLABLE-PRINCIPLES.md #2 the renderer source-of-truth is +// the same code in both places. The promotion to pkg/ exists for +// exactly this reason. +// - The diff is currently EMPTY: catalyst-api does not yet read the +// per-Org Gitea repo state for diffing in slice I. A follow-up +// slice can wire `core/controllers/pkg/gitea` here to compute the +// true unified diff vs current state. The wire-shape is +// forward-compatible. +// +// Per docs/INVIOLABLE-PRINCIPLES.md #4 the renderer config (interval +// seconds, source kind, source ref) is read from the Blueprint, never +// hardcoded. +package handler + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/openova-io/openova/core/controllers/pkg/render" + "github.com/openova-io/openova/core/controllers/pkg/validate" + "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/auth" +) + +// ── Wire shapes ────────────────────────────────────────────────────── + +// applicationPreviewRequest is the body of POST .../applications/preview. +// Same shape as applicationInstallRequest except `name` is optional. +type applicationPreviewRequest struct { + BlueprintRef applicationBlueprintRef `json:"blueprintRef"` + Name string `json:"name,omitempty"` + OrganizationRef string `json:"organizationRef"` + EnvironmentRef string `json:"environmentRef"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Placement applicationPlacement `json:"placement"` +} + +// PreviewManifest is one rendered file in the preview output. Path is +// the in-Gitea path the application-controller will write at install +// time; content is the YAML byte stream rendered for that path. +type PreviewManifest struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// applicationPreviewBlueprintRef mirrors the Blueprint in the response +// so the UI's preview modal can show "previewing wordpress@5.6.1" +// without cross-referencing the request. +type applicationPreviewBlueprintRef struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// applicationPreviewResponse is the body of POST .../preview. +type applicationPreviewResponse struct { + Manifests []PreviewManifest `json:"manifests"` + Diff string `json:"diff"` + Blueprint applicationPreviewBlueprintRef `json:"blueprint"` + Warnings []string `json:"warnings"` +} + +// HandleApplicationPreview — POST /api/v1/sovereigns/{id}/applications/preview +// +// See file-level doc for the full contract. +func (h *Handler) HandleApplicationPreview(w http.ResponseWriter, r *http.Request) { + depID := chi.URLParam(r, "id") + dep, ok := h.lookupDeploymentForInfra(depID) + if !ok { + writeNotFound(w, depID) + return + } + if h.catalogClient == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "catalog-not-wired", + "detail": "catalog client unconfigured (CATALYST_CATALOG_URL); preview requires the catalog upstream", + }) + return + } + + var body applicationPreviewRequest + if !decodeMutationBody(w, r, &body) { + return + } + if msg, ok := validateApplicationPreviewRequest(body); !ok { + writeBadRequest(w, "invalid-application-preview", msg) + return + } + + if claims := auth.ClaimsFromContext(r.Context()); claims != nil { + if !applicationInstallCallerAuthorized(claims) { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "forbidden", + "detail": "POST /applications/preview requires tier-admin or higher", + }) + return + } + } + + bp, err := h.catalogClient.GetVersion( + r.Context(), + body.BlueprintRef.Name, + body.BlueprintRef.Version, + applicationSessionToken(r), + ) + if err != nil { + if errors.Is(err, ErrBlueprintNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "blueprint-not-found", + "detail": fmt.Sprintf("blueprint %s@%s is not in the catalog", body.BlueprintRef.Name, body.BlueprintRef.Version), + }) + return + } + h.log.Warn("application preview: catalog fetch failed", + "depId", depID, "blueprint", body.BlueprintRef.Name, "version", body.BlueprintRef.Version, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "catalog-upstream", + "detail": err.Error(), + }) + return + } + + // Validate parameters (same gate as install). Keep the renderer + // from emitting nonsense for invalid inputs. + configSchema := blueprintConfigSchema(bp) + rep, vErr := validate.Parameters(configSchema, body.Parameters) + if vErr != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "blueprint-schema-malformed", + "detail": vErr.Error(), + }) + return + } + if !rep.Valid { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "invalid-parameters", + "detail": "parameters do not satisfy Blueprint.spec.configSchema", + "errors": rep.Errors, + }) + return + } + + // Render manifests for each region. Standby flag follows the + // active-hotstandby pattern: regions[0] is primary, the rest are + // standby (replicas: 0 overlay). + appName := strings.TrimSpace(body.Name) + if appName == "" { + appName = body.BlueprintRef.Name + "-preview" + } + envType := environmentTypeFromName(body.EnvironmentRef) + manifests := make([]PreviewManifest, 0, len(body.Placement.Regions)*2) + warnings := []string{} + + for i, region := range body.Placement.Regions { + standby := body.Placement.Mode == "active-hotstandby" && i > 0 + role := previewRoleForPlacement(body.Placement.Mode, i) + in := render.Inputs{ + AppName: appName, + Org: body.OrganizationRef, + EnvType: envType, + Region: region, + PlacementRole: role, + Standby: standby, + BlueprintName: body.BlueprintRef.Name, + BlueprintVersion: body.BlueprintRef.Version, + SourceKind: blueprintSourceKind(bp), + SourceRef: blueprintSourceRef(bp), + Chart: blueprintChart(bp), + Values: body.Parameters, + } + out, rErr := render.Render(in) + if rErr != nil { + h.log.Warn("application preview: render failed", + "depId", depID, "blueprint", body.BlueprintRef.Name, "region", region, "err", rErr) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "render-failed", + "detail": rErr.Error(), + }) + return + } + basePath := fmt.Sprintf("clusters/%s/applications/%s", region, appName) + manifests = append(manifests, PreviewManifest{ + Path: basePath + "/kustomization.yaml", + Content: string(out.KustomizationYAML), + }) + manifests = append(manifests, PreviewManifest{ + Path: basePath + "/helmrelease.yaml", + Content: string(out.HelmReleaseYAML), + }) + } + + // Diff against the per-Org Gitea repo's current state is deferred: + // the unified Gitea client is wired for the catalog (read-side + // blueprint-yaml fetches) but the preview-vs-install diff against + // `/` is a follow-up. The empty string is a valid value; + // the UI shows "no prior state" when diff is empty. + diff := "" + if len(manifests) > 0 { + warnings = append(warnings, "preview shows the manifests catalyst-api will commit; live-vs-preview diff against the per-Org Gitea repo is deferred to a follow-up slice") + } + + writeJSON(w, http.StatusOK, applicationPreviewResponse{ + Manifests: manifests, + Diff: diff, + Blueprint: applicationPreviewBlueprintRef{ + Name: bp.Name, + Version: bp.Version, + }, + Warnings: warnings, + }) + _ = dep // kept for future per-Sovereign Gitea diff +} + +// validateApplicationPreviewRequest mirrors the install validator with +// `name` optional. Keeps the two paths in lockstep so a 400 on preview +// equates to a 400 on install. +func validateApplicationPreviewRequest(req applicationPreviewRequest) (string, bool) { + if strings.TrimSpace(req.OrganizationRef) == "" { + return "organizationRef is required", false + } + if !isValidK8sName(req.OrganizationRef) { + return "organizationRef must be a valid K8s name", false + } + if strings.TrimSpace(req.EnvironmentRef) == "" { + return "environmentRef is required", false + } + if strings.TrimSpace(req.BlueprintRef.Name) == "" { + return "blueprintRef.name is required", false + } + if !strings.HasPrefix(req.BlueprintRef.Name, "bp-") { + return "blueprintRef.name must be of the form bp-", false + } + if strings.TrimSpace(req.BlueprintRef.Version) == "" { + return "blueprintRef.version is required", false + } + if strings.TrimSpace(req.Placement.Mode) == "" { + return "placement.mode is required", false + } + switch req.Placement.Mode { + case "single-region", "active-active", "active-hotstandby": + default: + return "placement.mode must be one of single-region, active-active, active-hotstandby", false + } + if len(req.Placement.Regions) == 0 { + return "placement.regions must list at least one region", false + } + if req.Name != "" && !isValidK8sName(req.Name) { + return "name must be a valid K8s name when provided", false + } + return "", true +} + +// blueprintSourceKind reads `spec.manifests.source.kind` (or empty). +func blueprintSourceKind(bp *CatalogBlueprint) string { + return blueprintNestedString(bp, "spec", "manifests", "source", "kind") +} + +// blueprintSourceRef reads `spec.manifests.source.ref` (or empty). +func blueprintSourceRef(bp *CatalogBlueprint) string { + return blueprintNestedString(bp, "spec", "manifests", "source", "ref") +} + +// blueprintChart reads `spec.manifests.chart` (or empty — render +// defaults to BlueprintName when this is empty). +func blueprintChart(bp *CatalogBlueprint) string { + return blueprintNestedString(bp, "spec", "manifests", "chart") +} + +// blueprintNestedString — convenience wrapper to drill into the +// Blueprint's Raw map without panicking on missing keys. +func blueprintNestedString(bp *CatalogBlueprint, keys ...string) string { + if bp == nil || bp.Raw == nil { + return "" + } + var cur any = map[string]interface{}(bp.Raw) + for _, k := range keys { + m, ok := cur.(map[string]interface{}) + if !ok { + return "" + } + cur = m[k] + if cur == nil { + return "" + } + } + if s, ok := cur.(string); ok { + return s + } + return "" +} + +// environmentTypeFromName parses the env type (`-prod`, `-stg`, `-dev`, +// `-uat`, `-poc`) suffix from the Environment name. Falls back to the +// full name when no suffix matches; the renderer treats that as a +// label-only field so a non-canonical env name is not fatal. +func environmentTypeFromName(env string) string { + for _, suf := range []string{"-prod", "-stg", "-dev", "-uat", "-poc"} { + if strings.HasSuffix(env, suf) { + return strings.TrimPrefix(suf, "-") + } + } + return env +} + +// previewRoleForPlacement maps (mode, index) → renderer's PlacementRole +// label. Mirrors the application-controller's reconcile contract so +// preview labels match production labels. +func previewRoleForPlacement(mode string, idx int) string { + switch mode { + case "active-active": + return "active" + case "active-hotstandby": + if idx == 0 { + return "primary" + } + return "standby" + } + return "primary" +} diff --git a/products/catalyst/bootstrap/api/internal/handler/applications_test.go b/products/catalyst/bootstrap/api/internal/handler/applications_test.go new file mode 100644 index 00000000..d00dffc2 --- /dev/null +++ b/products/catalyst/bootstrap/api/internal/handler/applications_test.go @@ -0,0 +1,614 @@ +// applications_test.go — coverage for the EPIC-2 Slice I (#1097) +// install + preview endpoints. +// +// Test strategy mirrors rbac_assign_test.go: a fake dynamic client +// seeded with the Application GVR's list-kind, an installed Deployment +// with a temp-file kubeconfig path so sovereignDynamicClient resolves, +// a stub CatalogClient injected via SetCatalogClient, and a per-test +// chi router that registers only the endpoint under test. +// +// Five POST /applications paths exercised: +// - 201 created (happy path) +// - 400 invalid parameters (configSchema gate) +// - 400 missing required field (handler-level validation) +// - 403 unauthorized (claims missing required role) +// - 404 unknown blueprint (catalog client returns ErrBlueprintNotFound) +// - 409 duplicate Application +// +// Plus preview tests: 200 happy + 400 invalid parameters. +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/auth" +) + +// ── Test helpers ───────────────────────────────────────────────────── + +// fakeApplicationDynamicFactory returns a dynamic-client factory + the +// underlying fake client so tests can both inject the factory into the +// handler and inspect/seed the tracker. +func fakeApplicationDynamicFactory(seed ...runtime.Object) (func(string) (dynamic.Interface, error), *dynamicfake.FakeDynamicClient) { + scheme := runtime.NewScheme() + listKinds := map[schema.GroupVersionResource]string{ + ApplicationGVR(): "ApplicationList", + } + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, seed...) + return func(_ string) (dynamic.Interface, error) { + return client, nil + }, client +} + +// fakeCatalogClient is a stub CatalogClient. Lookups are case-sensitive +// on (name, version); a miss returns ErrBlueprintNotFound. +type fakeCatalogClient struct { + byKey map[string]*CatalogBlueprint + getErr error +} + +func newFakeCatalog(bps ...*CatalogBlueprint) *fakeCatalogClient { + c := &fakeCatalogClient{byKey: map[string]*CatalogBlueprint{}} + for _, bp := range bps { + c.byKey[bp.Name+"@"+bp.Version] = bp + } + return c +} + +func (c *fakeCatalogClient) List(_ context.Context, _ string, _ string) ([]CatalogBlueprint, error) { + out := make([]CatalogBlueprint, 0, len(c.byKey)) + for _, bp := range c.byKey { + out = append(out, *bp) + } + return out, nil +} + +func (c *fakeCatalogClient) Get(_ context.Context, name string, _ string) (*CatalogBlueprint, error) { + if c.getErr != nil { + return nil, c.getErr + } + for _, bp := range c.byKey { + if bp.Name == name { + return bp, nil + } + } + return nil, ErrBlueprintNotFound +} + +func (c *fakeCatalogClient) GetVersion(_ context.Context, name, version string, _ string) (*CatalogBlueprint, error) { + if c.getErr != nil { + return nil, c.getErr + } + bp, ok := c.byKey[name+"@"+version] + if !ok { + return nil, ErrBlueprintNotFound + } + return bp, nil +} + +// sampleWordpressBlueprint composes a CatalogBlueprint with a non-trivial +// configSchema so validate.Parameters has something to enforce. Keep +// the schema small + canonical: a `domain` string (required) + a +// `replicas` integer with a min/max range. +func sampleWordpressBlueprint() *CatalogBlueprint { + return &CatalogBlueprint{ + Name: "bp-wordpress", + Version: "1.2.3", + Card: CatalogBlueprintCard{ + Title: "WordPress", + Summary: "PHP CMS", + }, + Origin: 1, + Source: "public", + Raw: map[string]interface{}{ + "spec": map[string]interface{}{ + "version": "1.2.3", + "manifests": map[string]interface{}{ + "chart": "wordpress", + "source": map[string]interface{}{ + "kind": "HelmRepository", + "ref": "bitnami", + }, + }, + "configSchema": map[string]interface{}{ + "type": "object", + "required": []interface{}{"domain"}, + "properties": map[string]interface{}{ + "domain": map[string]interface{}{ + "type": "string", + }, + "replicas": map[string]interface{}{ + "type": "integer", + "minimum": float64(1), + "maximum": float64(5), + }, + }, + "additionalProperties": false, + }, + }, + }, + } +} + +func registerApplicationRoutes(r chi.Router, h *Handler) { + r.Post("/api/v1/sovereigns/{id}/applications", h.HandleApplicationInstall) + r.Post("/api/v1/sovereigns/{id}/applications/preview", h.HandleApplicationPreview) + r.Get("/api/v1/sovereigns/{id}/applications/{name}/status", h.HandleApplicationStatus) +} + +// ── 201 happy path ─────────────────────────────────────────────────── + +func TestHandleApplicationInstall_CreatesApplicationCR(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, client := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-create") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Parameters: map[string]interface{}{ + "domain": "shop.acme.com", + "replicas": float64(2), + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusCreated { + t.Fatalf("status: got %d want 201; body=%s", rec.Code, rec.Body.String()) + } + var resp applicationInstallResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Name != "wp-prod" || resp.Namespace != "acme" { + t.Fatalf("name/ns: got %q/%q", resp.Name, resp.Namespace) + } + + // Verify the CR was created with the right shape. + got, err := client.Resource(ApplicationGVR()).Namespace("acme").Get( + context.Background(), "wp-prod", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get: %v", err) + } + if v, _, _ := unstructured.NestedString(got.Object, "spec", "blueprintRef", "name"); v != "bp-wordpress" { + t.Fatalf("spec.blueprintRef.name: got %q", v) + } + if v, _, _ := unstructured.NestedString(got.Object, "spec", "environmentRef"); v != "acme-prod" { + t.Fatalf("spec.environmentRef: got %q", v) + } + if v, _, _ := unstructured.NestedString(got.Object, "spec", "placement"); v != "single-region" { + t.Fatalf("spec.placement: got %q", v) + } + regions, _, _ := unstructured.NestedStringSlice(got.Object, "spec", "regions") + if len(regions) != 1 || regions[0] != "hz-fsn-rtz-prod" { + t.Fatalf("spec.regions: got %v", regions) + } + if v, _, _ := unstructured.NestedString(got.Object, "spec", "parameters", "domain"); v != "shop.acme.com" { + t.Fatalf("spec.parameters.domain: got %q", v) + } + labels := got.GetLabels() + if labels["catalyst.openova.io/blueprint"] != "bp-wordpress" { + t.Fatalf("blueprint label: got %q", labels["catalyst.openova.io/blueprint"]) + } +} + +// ── 400 invalid-parameters via JSON-Schema validator ───────────────── + +func TestHandleApplicationInstall_RejectsInvalidParameters(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-bad-params") + + // Missing required `domain` field. + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Parameters: map[string]interface{}{ + "replicas": float64(2), + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp["error"] != "invalid-parameters" { + t.Fatalf("error: got %v want invalid-parameters", resp["error"]) + } + errs, _ := resp["errors"].([]interface{}) + if len(errs) == 0 { + t.Fatalf("expected at least one schema error; got %v", resp) + } +} + +// ── 400 missing required handler-level field ───────────────────────── + +func TestHandleApplicationInstall_RejectsMissingName(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-no-name") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + // Name omitted. + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if !strings.Contains(resp["detail"], "name is required") { + t.Fatalf("detail: got %q", resp["detail"]) + } +} + +// ── 403 caller lacks tier-admin ────────────────────────────────────── + +func TestHandleApplicationInstall_ForbiddenWhenNotTierAdmin(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-403") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Parameters: map[string]interface{}{ + "domain": "shop.acme.com", + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + + // Use claimsAuthCaller which seeds a non-privileged user. + rec := callApplicationWithClaims(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, &auth.Claims{ + Sub: "alice", + }) + if rec.Code != http.StatusForbidden { + t.Fatalf("status: got %d want 403; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── 404 unknown blueprint ──────────────────────────────────────────── + +func TestHandleApplicationInstall_404OnUnknownBlueprint(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog()) // empty catalog + dep := installUserAccessDeployment(t, h, "dep-app-404") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-nobody", Version: "9.9.9"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusNotFound { + t.Fatalf("status: got %d want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── 409 duplicate ──────────────────────────────────────────────────── + +func TestHandleApplicationInstall_409OnDuplicate(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + existing := &unstructured.Unstructured{} + existing.SetAPIVersion("apps.openova.io/v1") + existing.SetKind("Application") + existing.SetName("wp-prod") + existing.SetNamespace("acme") + factory, _ := fakeApplicationDynamicFactory(existing) + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-409") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Parameters: map[string]interface{}{ + "domain": "shop.acme.com", + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusConflict { + t.Fatalf("status: got %d want 409; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── 503 catalog unwired ────────────────────────────────────────────── + +func TestHandleApplicationInstall_503WhenCatalogUnwired(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + // catalog client NOT wired + dep := installUserAccessDeployment(t, h, "dep-app-503") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status: got %d want 503; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── status snapshot ────────────────────────────────────────────────── + +func TestHandleApplicationStatus_ReturnsRolledUpStatus(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + app := &unstructured.Unstructured{} + app.SetAPIVersion("apps.openova.io/v1") + app.SetKind("Application") + app.SetName("wp-prod") + app.SetNamespace("acme") + _ = unstructured.SetNestedField(app.Object, "Ready", "status", "phase") + _ = unstructured.SetNestedField(app.Object, "hz-fsn-rtz-prod", "status", "primaryRegion") + factory, _ := fakeApplicationDynamicFactory(app) + h.dynamicFactory = factory + dep := installUserAccessDeployment(t, h, "dep-app-status") + + rec := callUserAccess(t, h, http.MethodGet, + "/api/v1/sovereigns/"+dep.ID+"/applications/wp-prod/status", nil, registerApplicationRoutes) + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp applicationStatusResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Phase != "Ready" { + t.Fatalf("phase: got %q want Ready", resp.Phase) + } + if resp.Name != "wp-prod" || resp.Namespace != "acme" { + t.Fatalf("name/ns: got %q/%q", resp.Name, resp.Namespace) + } +} + +func TestHandleApplicationStatus_404OnMissing(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + dep := installUserAccessDeployment(t, h, "dep-app-status-miss") + + rec := callUserAccess(t, h, http.MethodGet, + "/api/v1/sovereigns/"+dep.ID+"/applications/missing/status", nil, registerApplicationRoutes) + if rec.Code != http.StatusNotFound { + t.Fatalf("status: got %d want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── preview ────────────────────────────────────────────────────────── + +func TestHandleApplicationPreview_RendersManifests(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-prev") + + body := applicationPreviewRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Parameters: map[string]interface{}{ + "domain": "shop.acme.com", + "replicas": float64(2), + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications/preview", body, registerApplicationRoutes) + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp applicationPreviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Manifests) != 2 { + t.Fatalf("manifests count: got %d want 2 (kustomization + helmrelease)", len(resp.Manifests)) + } + wantPaths := map[string]bool{ + "clusters/hz-fsn-rtz-prod/applications/wp-prod/kustomization.yaml": false, + "clusters/hz-fsn-rtz-prod/applications/wp-prod/helmrelease.yaml": false, + } + for _, m := range resp.Manifests { + if _, ok := wantPaths[m.Path]; !ok { + t.Fatalf("unexpected manifest path %q", m.Path) + } + wantPaths[m.Path] = true + if m.Content == "" { + t.Fatalf("empty content for %q", m.Path) + } + } + for p, seen := range wantPaths { + if !seen { + t.Fatalf("missing manifest path %q", p) + } + } + if resp.Blueprint.Name != "bp-wordpress" || resp.Blueprint.Version != "1.2.3" { + t.Fatalf("blueprint: got %+v", resp.Blueprint) + } +} + +func TestHandleApplicationPreview_RejectsInvalidParameters(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint())) + dep := installUserAccessDeployment(t, h, "dep-app-prev-bad") + + body := applicationPreviewRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + // Missing required `domain` parameter. + Parameters: map[string]interface{}{ + "replicas": float64(99), // also out of range + }, + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications/preview", body, registerApplicationRoutes) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── helpers ────────────────────────────────────────────────────────── + +// callApplicationWithClaims drives a request through the handler with +// a pre-populated auth.Claims in the request context. The route is +// registered on a fresh chi router so middleware doesn't interfere. +func callApplicationWithClaims( + t *testing.T, + h *Handler, + method, path string, + body any, + claims *auth.Claims, +) *httptest.ResponseRecorder { + t.Helper() + r := chi.NewRouter() + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := context.WithValue(req.Context(), auth.ClaimsKey, claims) + next.ServeHTTP(w, req.WithContext(ctx)) + }) + }) + registerApplicationRoutes(r, h) + var buf *bytes.Buffer + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + buf = bytes.NewBuffer(raw) + } else { + buf = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, path, buf) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +// ── interface compile-time check ───────────────────────────────────── + +var _ CatalogClient = (*fakeCatalogClient)(nil) + +// ── error path coverage ────────────────────────────────────────────── + +func TestHandleApplicationInstall_502OnCatalogUpstreamError(t *testing.T) { + h := NewWithPDM(silentLogger(), &fakePDM{}) + factory, _ := fakeApplicationDynamicFactory() + h.dynamicFactory = factory + c := newFakeCatalog() + c.getErr = errors.New("connection refused") + h.SetCatalogClient(c) + dep := installUserAccessDeployment(t, h, "dep-app-502") + + body := applicationInstallRequest{ + BlueprintRef: applicationBlueprintRef{Name: "bp-wordpress", Version: "1.2.3"}, + Name: "wp-prod", + OrganizationRef: "acme", + EnvironmentRef: "acme-prod", + Placement: applicationPlacement{ + Mode: "single-region", + Regions: []string{"hz-fsn-rtz-prod"}, + }, + } + rec := callUserAccess(t, h, http.MethodPost, + "/api/v1/sovereigns/"+dep.ID+"/applications", body, registerApplicationRoutes) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status: got %d want 502; body=%s", rec.Code, rec.Body.String()) + } +} + +// ── apierrors helper used to coerce a fake-client conflict into a +// deterministic 409 path. Currently no test path uses it (the fake +// client surfaces AlreadyExists naturally for duplicate Create), but +// keep the import-check by referencing once. +var _ = apierrors.IsConflict diff --git a/products/catalyst/bootstrap/api/internal/handler/catalog_client.go b/products/catalyst/bootstrap/api/internal/handler/catalog_client.go new file mode 100644 index 00000000..4a014ec5 --- /dev/null +++ b/products/catalyst/bootstrap/api/internal/handler/catalog_client.go @@ -0,0 +1,243 @@ +// Package handler — catalog_client.go: EPIC-2 Slice I (#1097) thin +// REST client to catalyst-catalog (slice L, #1148). +// +// The catalyst-api install + preview handlers call into catalyst-catalog +// via this client (proxy mode, NOT direct from the UI): +// +// UI ─POST /api/v1/sovereigns/{id}/applications─▶ catalyst-api ──┐ +// │ GET /api/v1/catalog/{name}/versions/{version} +// ▼ +// catalyst-catalog +// +// Why proxy: the UI presents one tier-prefixed origin; catalyst-api is +// the single auth-enforcement seam (Keycloak JWT validation already +// happens here). Adding a second cross-origin call from the browser to +// catalyst-catalog would force CORS + duplicate token-handling for no +// architectural gain. catalyst-catalog stays behind the catalyst-api +// Cilium-Gateway HTTPRoute as designed in slice L's DESIGN.md §"Auth +// model". +// +// Per docs/INVIOLABLE-PRINCIPLES.md #4 the upstream URL is configurable +// (CATALYST_CATALOG_URL env var); the in-cluster service FQDN +// `http://catalyst-catalog.openova-system.svc.cluster.local:8080` is +// the production default but a debug deployment can point this at a +// localhost catalog for offline iteration. +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// CatalogBlueprint is the wire shape catalyst-catalog returns. Mirrors +// `core/services/catalyst-catalog/internal/source.Blueprint`. +// +// `Raw` is populated on the GET-by-version endpoint so the install +// handler can validate parameters against `spec.configSchema` without +// a second round-trip to the catalog. +type CatalogBlueprint struct { + Name string `json:"name"` + Version string `json:"version"` + Visibility string `json:"visibility,omitempty"` + Card CatalogBlueprintCard `json:"card"` + PlacementSchema *CatalogPlacement `json:"placementSchema,omitempty"` + UpgradeFrom []string `json:"upgradeFrom,omitempty"` + UpgradeBlocks []string `json:"upgradeBlocks,omitempty"` + Origin int `json:"origin"` + Source string `json:"source"` + Org string `json:"org,omitempty"` + Raw map[string]interface{} `json:"raw,omitempty"` +} + +// CatalogBlueprintCard mirrors `spec.card` block. +type CatalogBlueprintCard struct { + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Tagline string `json:"tagline,omitempty"` + Icon string `json:"icon,omitempty"` + Category string `json:"category,omitempty"` + Family string `json:"family,omitempty"` + Tags []string `json:"tags,omitempty"` + License string `json:"license,omitempty"` + Docs string `json:"docs,omitempty"` +} + +// CatalogPlacement mirrors `spec.placementSchema` (subset). +type CatalogPlacement struct { + Modes []string `json:"modes,omitempty"` + Default string `json:"default,omitempty"` + MinRegions int `json:"minRegions,omitempty"` + MaxRegions int `json:"maxRegions,omitempty"` +} + +// CatalogListResponse is the body of GET /api/v1/catalog. +type CatalogListResponse struct { + Items []CatalogBlueprint `json:"items"` +} + +// ErrBlueprintNotFound surfaces a 404 from catalyst-catalog so the +// install handler can return its own 404 to the UI. +var ErrBlueprintNotFound = errors.New("catalog: blueprint not found") + +// CatalogClient is a narrow REST client to catalyst-catalog. The caller +// passes the request context through so catalyst-api's per-request +// timeouts / cancellations propagate to the upstream call. +type CatalogClient interface { + List(ctx context.Context, org, sessionToken string) ([]CatalogBlueprint, error) + Get(ctx context.Context, name, sessionToken string) (*CatalogBlueprint, error) + GetVersion(ctx context.Context, name, version, sessionToken string) (*CatalogBlueprint, error) +} + +// httpCatalogClient is the production implementation. Tests substitute +// a stub via SetCatalogClient. +type httpCatalogClient struct { + baseURL string + httpClient *http.Client +} + +// defaultCatalogURL is the in-cluster service FQDN. Per +// docs/INVIOLABLE-PRINCIPLES.md #4 it is configuration-overridable via +// CATALYST_CATALOG_URL. +const defaultCatalogURL = "http://catalyst-catalog.openova-system.svc.cluster.local:8080" + +// NewCatalogClientFromEnv reads CATALYST_CATALOG_URL (defaulting to the +// in-cluster service FQDN) and returns the production client. +func NewCatalogClientFromEnv() CatalogClient { + base := strings.TrimRight(os.Getenv("CATALYST_CATALOG_URL"), "/") + if base == "" { + base = defaultCatalogURL + } + return &httpCatalogClient{ + baseURL: base, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// List fetches `/api/v1/catalog?org=` and returns the items array. +// `sessionToken` is forwarded as a Cookie matching catalyst-catalog's +// configured SessionCookieName when non-empty; otherwise the call is +// anonymous (catalyst-catalog falls through to its AnonymousReads +// policy). +func (c *httpCatalogClient) List(ctx context.Context, org, sessionToken string) ([]CatalogBlueprint, error) { + u := c.baseURL + "/api/v1/catalog" + if org != "" { + u += "?org=" + url.QueryEscape(org) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("catalog list: build request: %w", err) + } + c.attachAuth(req, sessionToken) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("catalog list: do request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("catalog list: upstream %d %s", resp.StatusCode, readErrSnippet(resp.Body)) + } + var body CatalogListResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("catalog list: decode: %w", err) + } + return body.Items, nil +} + +// Get fetches `/api/v1/catalog/{name}` (latest visible version). +func (c *httpCatalogClient) Get(ctx context.Context, name, sessionToken string) (*CatalogBlueprint, error) { + if strings.TrimSpace(name) == "" { + return nil, errors.New("catalog get: name is required") + } + u := c.baseURL + "/api/v1/catalog/" + url.PathEscape(name) + return c.fetchOne(ctx, u, sessionToken) +} + +// GetVersion fetches `/api/v1/catalog/{name}/versions/{version}` — +// returns the Blueprint at the requested version. The Raw field is +// populated by catalyst-catalog on this endpoint per slice L's contract. +func (c *httpCatalogClient) GetVersion(ctx context.Context, name, version, sessionToken string) (*CatalogBlueprint, error) { + if strings.TrimSpace(name) == "" || strings.TrimSpace(version) == "" { + return nil, errors.New("catalog getVersion: name and version are required") + } + u := c.baseURL + "/api/v1/catalog/" + url.PathEscape(name) + "/versions/" + url.PathEscape(version) + return c.fetchOne(ctx, u, sessionToken) +} + +func (c *httpCatalogClient) fetchOne(ctx context.Context, u, sessionToken string) (*CatalogBlueprint, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("catalog get: build request: %w", err) + } + c.attachAuth(req, sessionToken) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("catalog get: do request: %w", err) + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + var bp CatalogBlueprint + if err := json.NewDecoder(resp.Body).Decode(&bp); err != nil { + return nil, fmt.Errorf("catalog get: decode: %w", err) + } + return &bp, nil + case http.StatusNotFound: + return nil, ErrBlueprintNotFound + default: + return nil, fmt.Errorf("catalog get: upstream %d %s", resp.StatusCode, readErrSnippet(resp.Body)) + } +} + +// attachAuth wires the session token into the upstream request. The +// catalog service reads its session cookie by configurable name. For +// the in-cluster proxy hop we forward as both `Authorization: Bearer +// ` and `Cookie: catalyst_session=` so the catalog accepts +// whichever pattern its env-config selects. +// +// Per docs/INVIOLABLE-PRINCIPLES.md #10 the credential never reaches +// the terminal: this function does NOT log the token. +func (c *httpCatalogClient) attachAuth(req *http.Request, sessionToken string) { + if sessionToken == "" { + return + } + req.Header.Set("Authorization", "Bearer "+sessionToken) + // Catalog's default cookie name is `catalyst_session` per the + // slice L config; we forward under that name. If the deployment + // overrides it, the operator must set the same name on both sides + // — unrelated to this client. + req.AddCookie(&http.Cookie{Name: "catalyst_session", Value: sessionToken}) +} + +// readErrSnippet returns up to the first 256 bytes of an error body so +// upstream failures surface a short, log-safe context to the caller. +// Truncation is by RUNES of the head; we never echo unbounded upstream +// responses to the catalyst-api log line. +func readErrSnippet(r io.Reader) string { + buf := make([]byte, 256) + n, _ := io.ReadFull(io.LimitReader(r, 256), buf) + out := strings.TrimSpace(string(buf[:n])) + if out == "" { + return "" + } + return out +} + +// ── Wiring on *Handler ─────────────────────────────────────────────────── + +// SetCatalogClient injects a CatalogClient. main.go calls this once at +// startup; tests inject a stub directly. +func (h *Handler) SetCatalogClient(c CatalogClient) { h.catalogClient = c } + +// CatalogClient returns the wired client (or nil if unwired). +func (h *Handler) CatalogClient() CatalogClient { return h.catalogClient } diff --git a/products/catalyst/bootstrap/api/internal/handler/handler.go b/products/catalyst/bootstrap/api/internal/handler/handler.go index 480a3953..43838407 100644 --- a/products/catalyst/bootstrap/api/internal/handler/handler.go +++ b/products/catalyst/bootstrap/api/internal/handler/handler.go @@ -325,6 +325,17 @@ type Handler struct { // from main.go at startup AFTER k8sCache is up; tests set this // directly via SetComplianceHandler. compliance *ComplianceHandler + + // ── Live install flow (EPIC-2 #1097 slice I) ─────────────────── + // catalogClient — REST client to catalyst-catalog (slice L, + // #1148). The install + preview handlers use this to fetch + // Blueprint definitions before validating parameters and + // creating Application CRs. Nil-tolerant: when nil the + // /api/v1/sovereigns/{id}/applications endpoints return 503 + // ("catalog client not wired"). Wired from main.go at startup + // from CATALYST_CATALOG_URL; tests inject a stub via + // SetCatalogClient. + catalogClient CatalogClient } // powerdnsZoneClient is the narrow interface the parent-zone handler diff --git a/products/catalyst/bootstrap/ui/e2e/install-flow.spec.ts b/products/catalyst/bootstrap/ui/e2e/install-flow.spec.ts new file mode 100644 index 00000000..be44aba3 --- /dev/null +++ b/products/catalyst/bootstrap/ui/e2e/install-flow.spec.ts @@ -0,0 +1,280 @@ +/** + * install-flow.spec.ts — Playwright E2E for the EPIC-2 Slice I (#1097) + * live install flow. + * + * What this asserts (per `feedback_per_issue_playwright_verification.md` + * — N issues = N snapshots, never collapse): + * + * 1. /console/install renders the catalog grid from the live useCatalog hook + * 2. Click a Blueprint card → selects, renders fixed scaffold + auto-form + * 3. Submit form → POST verified + * 4. Preview → modal shows manifests + paths + * 5. x-catalyst-ui-hint=password renders masked input + * 6. install-with-defaults branch (Blueprint without configSchema) + * + * Each test mounts mock catalyst-api responses so the page renders + * deterministically without a live backend, then captures one + * 1440x900 screenshot per route per assertion. + */ + +import { test, expect, type Page, type Route } from '@playwright/test' + +const DEPLOYMENT_ID = 'install-1097' + +const WORDPRESS_BP = { + name: 'bp-wordpress', + version: '1.2.3', + card: { + title: 'WordPress', + summary: 'PHP CMS for content websites', + category: 'cms', + }, + origin: 1, + source: 'public', +} + +const WORDPRESS_BP_RAW = { + ...WORDPRESS_BP, + raw: { + spec: { + version: '1.2.3', + configSchema: { + type: 'object', + required: ['domain'], + properties: { + domain: { type: 'string', title: 'Domain' }, + replicas: { type: 'integer', title: 'Replicas', minimum: 1, maximum: 5 }, + adminPassword: { + type: 'string', + title: 'Admin password', + 'x-catalyst-ui-hint': 'password', + }, + }, + }, + }, + }, +} + +const SIMPLE_BP = { + name: 'bp-simple', + version: '0.1.0', + card: { title: 'Simple App', summary: 'Stateless container with no parameters' }, + origin: 2, + source: 'sovereign', +} + +const SIMPLE_BP_RAW = { + ...SIMPLE_BP, + raw: { + spec: { version: '0.1.0' }, + }, +} + +async function mockInstallAPI(page: Page) { + await page.route(/.*\/api\/v1\/catalog(\?.*)?$/, (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [WORDPRESS_BP, SIMPLE_BP] }), + }) + }) + await page.route( + /.*\/api\/v1\/catalog\/bp-wordpress\/versions\/1\.2\.3$/, + (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(WORDPRESS_BP_RAW), + }) + }, + ) + await page.route( + /.*\/api\/v1\/catalog\/bp-simple\/versions\/0\.1\.0$/, + (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(SIMPLE_BP_RAW), + }) + }, + ) + + await page.route( + /.*\/api\/v1\/sovereigns\/[^/]+\/applications$/, + (route: Route) => { + if (route.request().method() === 'POST') { + const body = route.request().postDataJSON() + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + name: body.name, + namespace: body.organizationRef, + uid: 'fake-uid', + status: { phase: 'Pending' }, + }), + }) + return + } + route.continue() + }, + ) + + await page.route( + /.*\/api\/v1\/sovereigns\/[^/]+\/applications\/preview$/, + (route: Route) => { + const body = route.request().postDataJSON() + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + manifests: [ + { + path: 'clusters/hz-fsn-rtz-prod/applications/wp-prod/kustomization.yaml', + content: '# kustomization for wp-prod\napiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - helmrelease.yaml\n', + }, + { + path: 'clusters/hz-fsn-rtz-prod/applications/wp-prod/helmrelease.yaml', + content: `# HelmRelease for wp-prod\napiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: wp-prod\nspec:\n values:\n domain: ${body?.parameters?.domain ?? 'preview.test'}\n`, + }, + ], + diff: '', + blueprint: { name: body.blueprintRef.name, version: body.blueprintRef.version }, + warnings: ['preview shows the manifests catalyst-api will commit; live-vs-preview diff against the per-Org Gitea repo is deferred to a follow-up slice'], + }), + }) + }, + ) + + // Auth gate stubs. + await page.route(/.*\/api\/v1\/sovereign\/self$/, (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ deploymentId: DEPLOYMENT_ID, sovereignFQDN: 'install.example' }), + }) + }) + await page.route(/.*\/api\/v1\/whoami$/, (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sub: 'test', email: 'test@example.com' }), + }) + }) + await page.route(/.*\/api\/v1\/deployments\/[^/]+$/, (route: Route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ deploymentId: DEPLOYMENT_ID }), + }) + }) +} + +test.describe('Install flow (slice I, #1097)', () => { + test.use({ viewport: { width: 1440, height: 900 } }) + + test('I1: catalog grid renders blueprints from useCatalog', async ({ page }) => { + await mockInstallAPI(page) + await page.goto(`/provision/${DEPLOYMENT_ID}/install`) + await page.waitForLoadState('domcontentloaded') + await page.waitForTimeout(800) + await expect(page.getByTestId('install-page')).toBeVisible() + await expect(page.getByTestId('install-page-card-bp-wordpress')).toBeVisible() + await expect(page.getByTestId('install-page-card-bp-simple')).toBeVisible() + await page.screenshot({ + path: `playwright-report/install-i1-catalog-${DEPLOYMENT_ID}.png`, + fullPage: false, + }) + }) + + test('I2: clicking a blueprint renders the auto-form with configSchema fields', async ({ page }) => { + await mockInstallAPI(page) + await page.goto(`/provision/${DEPLOYMENT_ID}/install`) + await page.waitForTimeout(600) + await page.getByTestId('install-page-card-bp-wordpress').click() + await page.waitForTimeout(800) + // Fixed scaffold visible. + await expect(page.getByTestId('install-page-app-name')).toBeVisible() + await expect(page.getByTestId('install-page-org-ref')).toBeVisible() + await expect(page.getByTestId('install-page-env-ref')).toBeVisible() + await expect(page.getByTestId('install-page-region')).toBeVisible() + await expect(page.getByTestId('install-page-placement')).toBeVisible() + // Auto-form fields from configSchema. + await expect(page.locator('#root_domain')).toBeVisible() + await expect(page.locator('#root_replicas')).toBeVisible() + // Password hint engages the masked widget. + await expect(page.getByTestId('install-form-password-input')).toBeVisible() + await page.screenshot({ + path: `playwright-report/install-i2-form-${DEPLOYMENT_ID}.png`, + fullPage: false, + }) + }) + + test('I3: submit posts to /applications and shows status modal', async ({ page }) => { + await mockInstallAPI(page) + let installPostBody: unknown = null + page.on('request', (req) => { + if (req.method() === 'POST' && /\/applications$/.test(req.url())) { + installPostBody = req.postDataJSON() + } + }) + await page.goto(`/provision/${DEPLOYMENT_ID}/install`) + await page.waitForTimeout(600) + await page.getByTestId('install-page-card-bp-wordpress').click() + await page.waitForTimeout(800) + await page.locator('#root_domain').fill('shop.acme.com') + await page.locator('#root_replicas').fill('2') + await page.getByTestId('install-form-password-input').fill('SuperSecret123!@#') + await page.getByTestId('install-form-submit-btn').click() + await page.waitForTimeout(800) + expect(installPostBody).toBeTruthy() + expect(installPostBody).toMatchObject({ + blueprintRef: { name: 'bp-wordpress', version: '1.2.3' }, + organizationRef: 'default', + environmentRef: 'default-prod', + placement: { mode: 'single-region', regions: ['hz-fsn-rtz-prod'] }, + parameters: { domain: 'shop.acme.com', replicas: 2 }, + }) + await expect(page.getByTestId('install-page-status-modal')).toBeVisible() + await page.screenshot({ + path: `playwright-report/install-i3-status-modal-${DEPLOYMENT_ID}.png`, + fullPage: false, + }) + }) + + test('I4: preview button opens modal with manifests + warnings', async ({ page }) => { + await mockInstallAPI(page) + await page.goto(`/provision/${DEPLOYMENT_ID}/install`) + await page.waitForTimeout(600) + await page.getByTestId('install-page-card-bp-wordpress').click() + await page.waitForTimeout(800) + await page.locator('#root_domain').fill('preview.test') + await page.getByTestId('install-form-preview-btn').click() + await page.waitForTimeout(600) + await expect(page.getByTestId('install-page-preview-modal')).toBeVisible() + // Both manifest paths appear. + await expect( + page.getByTestId('install-page-preview-manifest-clusters/hz-fsn-rtz-prod/applications/wp-prod/kustomization.yaml'), + ).toBeVisible() + await expect( + page.getByTestId('install-page-preview-manifest-clusters/hz-fsn-rtz-prod/applications/wp-prod/helmrelease.yaml'), + ).toBeVisible() + await page.screenshot({ + path: `playwright-report/install-i4-preview-${DEPLOYMENT_ID}.png`, + fullPage: false, + }) + }) + + test('I5: install-with-defaults branch renders for blueprint without configSchema', async ({ page }) => { + await mockInstallAPI(page) + await page.goto(`/provision/${DEPLOYMENT_ID}/install`) + await page.waitForTimeout(600) + await page.getByTestId('install-page-card-bp-simple').click() + await page.waitForTimeout(800) + await expect(page.getByTestId('install-form-no-schema')).toBeVisible() + await page.screenshot({ + path: `playwright-report/install-i5-no-schema-${DEPLOYMENT_ID}.png`, + fullPage: false, + }) + }) +}) diff --git a/products/catalyst/bootstrap/ui/package-lock.json b/products/catalyst/bootstrap/ui/package-lock.json index 56de7d50..0a5c0cbf 100644 --- a/products/catalyst/bootstrap/ui/package-lock.json +++ b/products/catalyst/bootstrap/ui/package-lock.json @@ -24,6 +24,9 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@rjsf/core": "^5.24.6", + "@rjsf/utils": "^5.24.6", + "@rjsf/validator-ajv8": "^5.24.6", "@tabler/icons-react": "^3.41.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.91.2", @@ -2246,6 +2249,90 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rjsf/core": { + "version": "5.24.13", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz", + "integrity": "sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.4.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/utils": "^5.24.x", + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/utils": { + "version": "5.24.13", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.24.13.tgz", + "integrity": "sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "5.24.13", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.13.tgz", + "integrity": "sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/utils": "^5.24.x" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", @@ -3738,6 +3825,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3978,6 +4104,27 @@ "dev": true, "license": "MIT" }, + "node_modules/compute-gcd": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/compute-lcm": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", + "dependencies": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4587,7 +4734,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4604,6 +4750,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4938,7 +5100,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5025,6 +5186,29 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.4" + } + }, + "node_modules/json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", + "license": "MIT", + "dependencies": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5052,6 +5236,15 @@ "node": ">=6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5341,6 +5534,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5348,6 +5553,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5387,6 +5604,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-to-jsx": { + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -5471,6 +5705,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5718,6 +5961,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5927,7 +6187,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6447,6 +6706,39 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/validate.io-array": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", + "license": "MIT" + }, + "node_modules/validate.io-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==" + }, + "node_modules/validate.io-integer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", + "dependencies": { + "validate.io-number": "^1.0.3" + } + }, + "node_modules/validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "node_modules/validate.io-number": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/products/catalyst/bootstrap/ui/package.json b/products/catalyst/bootstrap/ui/package.json index 43cb0009..9b6d42ff 100644 --- a/products/catalyst/bootstrap/ui/package.json +++ b/products/catalyst/bootstrap/ui/package.json @@ -32,6 +32,9 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@rjsf/core": "^5.24.6", + "@rjsf/utils": "^5.24.6", + "@rjsf/validator-ajv8": "^5.24.6", "@tabler/icons-react": "^3.41.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.91.2", diff --git a/products/catalyst/bootstrap/ui/src/app/router.tsx b/products/catalyst/bootstrap/ui/src/app/router.tsx index 1bb063b5..38881dcb 100644 --- a/products/catalyst/bootstrap/ui/src/app/router.tsx +++ b/products/catalyst/bootstrap/ui/src/app/router.tsx @@ -59,6 +59,7 @@ import { MarketplaceProductPage } from '@/pages/marketplace/MarketplaceProductPa import { ProvisionPage } from '@/pages/provision/ProvisionPage' import { AppsPage } from '@/pages/sovereign/AppsPage' import { AppDetail } from '@/pages/sovereign/AppDetail' +import { InstallPage } from '@/pages/sovereign/InstallPage' import { JobsPage } from '@/pages/sovereign/JobsPage' import { JobDetail } from '@/pages/sovereign/JobDetail' import { JobsTimeline } from '@/pages/sovereign/JobsTimeline' @@ -906,6 +907,53 @@ const consoleAppDetailRoute = createRoute({ component: AppDetail, }) +// EPIC-2 Slice I (#1097) — live install flow. +// +// Two sibling URL trees per the same pattern as compliance dashboards +// (slice U): +// +// Mothership tenant operator (provision tree): +// /provision/$deploymentId/install — catalog landing +// /provision/$deploymentId/install/$blueprintName — Blueprint pre-selected +// +// Chroot Sovereign Console (consoleLayoutRoute children): +// /install — same surface, deploymentId resolved via /api/v1/sovereign/self +// /install/$blueprintName — Blueprint pre-selected +// +// Per the brief the install page reads catalyst-catalog via the +// catalyst-api proxy; the InstallForm widget auto-generates the form +// from spec.configSchema (RJSF + Ajv). Submit creates the Application +// CR, status modal subscribes to the SSE stream, "Open Apps" navigates +// back to the canonical AppsPage when the operator dismisses. +const provisionInstallRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/install', + component: InstallPage, + beforeLoad: provisionAuthGuard, +}) +const provisionInstallBlueprintRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/provision/$deploymentId/install/$blueprintName', + component: () => { + const { blueprintName } = provisionInstallBlueprintRoute.useParams() as { blueprintName: string } + return + }, + beforeLoad: provisionAuthGuard, +}) +const consoleInstallRoute = createRoute({ + getParentRoute: () => consoleLayoutRoute, + path: '/install', + component: InstallPage, +}) +const consoleInstallBlueprintRoute = createRoute({ + getParentRoute: () => consoleLayoutRoute, + path: '/install/$blueprintName', + component: () => { + const { blueprintName } = consoleInstallBlueprintRoute.useParams() as { blueprintName: string } + return + }, +}) + // /console/settings/marketplace — operator toggles marketplace mode on a // live Sovereign (issue #710 wave 3b). The page POSTs to // /api/v1/sovereigns/{id}/marketplace which commits the per-Sovereign @@ -1062,6 +1110,8 @@ const routeTree = rootRoute.addChildren([ deploymentsListRoute, provisionRoute, provisionAppRoute, + provisionInstallRoute, + provisionInstallBlueprintRoute, provisionJobsRoute, provisionJobsTimelineRoute, provisionJobDetailRoute, @@ -1091,6 +1141,8 @@ const routeTree = rootRoute.addChildren([ consoleDashboardRoute, consoleAppsRoute, consoleAppDetailRoute, + consoleInstallRoute, + consoleInstallBlueprintRoute, consoleJobsRoute, consoleJobsTimelineRoute, consoleJobDetailRoute, diff --git a/products/catalyst/bootstrap/ui/src/lib/catalog.api.ts b/products/catalyst/bootstrap/ui/src/lib/catalog.api.ts new file mode 100644 index 00000000..dcdac118 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/lib/catalog.api.ts @@ -0,0 +1,258 @@ +/** + * catalog.api.ts — typed REST client wrappers for the catalyst-catalog + * proxy hop on catalyst-api (EPIC-2 slice I, #1097). + * + * Wire path: + * + * browser ──/api/v1/sovereigns/{id}/catalog/...──▶ catalyst-api ──▶ catalyst-catalog + * + * The proxy is the only sanctioned path from the UI: catalyst-catalog + * runs behind the catalyst-api Cilium-Gateway HTTPRoute (per slice L's + * DESIGN.md §"Auth model"). A direct browser-to-catalog call would + * force CORS + duplicate token-handling for no architectural gain. + * + * For slice I we ship the install + preview endpoints, plus the + * catalog list/get/get-by-version endpoints under the same /sovereigns/{id} + * path. catalyst-api exposes: + * + * GET /api/v1/catalog — list (per slice L) + * GET /api/v1/catalog/{name} — get + * GET /api/v1/catalog/{name}/versions/{ver} — get version + * POST /api/v1/sovereigns/{id}/applications — install + * POST /api/v1/sovereigns/{id}/applications/preview — preview + * GET /api/v1/sovereigns/{id}/applications/{name}/status — status + * GET /api/v1/sovereigns/{id}/applications/{name}/stream — SSE + * + * Per docs/INVIOLABLE-PRINCIPLES.md #4 every URL derives from + * `API_BASE` so the contabo strip-sovereign / direct-Sovereign + * distinction is resolved at config-time, not in components. + */ + +import { API_BASE } from '@/shared/config/urls' +import { authedFetch } from '@/shared/lib/authedFetch' + +/* ── Wire types ──────────────────────────────────────────────────── */ + +export type CatalogOrigin = 1 | 2 | 3 +export type CatalogSource = 'public' | 'sovereign' | 'org-private' + +/** + * BlueprintCard mirrors `Blueprint.spec.card` per the CRD shape. + */ +export interface BlueprintCard { + title: string + summary?: string + description?: string + tagline?: string + icon?: string + category?: string + family?: string + tags?: string[] + license?: string + docs?: string +} + +/** + * BlueprintPlacement mirrors the placement-schema subset surfaced on + * each catalog item. + */ +export interface BlueprintPlacement { + modes?: string[] + default?: string + minRegions?: number + maxRegions?: number +} + +/** + * CatalogItem — wire shape returned by `GET /api/v1/catalog`. Mirrors + * `core/services/catalyst-catalog/internal/source.Blueprint`. + * + * `raw` is populated only by the per-version endpoint + * (`GET /api/v1/catalog/{name}/versions/{version}`) — that's the one + * the install flow uses to pull `spec.configSchema` for the auto-form. + */ +export interface CatalogItem { + name: string + version: string + visibility?: string + card: BlueprintCard + placementSchema?: BlueprintPlacement + upgradeFrom?: string[] + upgradeBlocks?: string[] + origin: CatalogOrigin + source: CatalogSource + org?: string + raw?: Record +} + +export interface CatalogListResponse { + items: CatalogItem[] +} + +export interface CatalogVersionsResponse { + name: string + versions: { version: string; origin: string; org?: string }[] + upgradeMatrix: Record +} + +/* ── Endpoint helpers ────────────────────────────────────────────── */ + +function catalogBase(): string { + return `${API_BASE}/v1/catalog` +} + +function applicationsBase(sovereignId: string): string { + return `${API_BASE}/v1/sovereigns/${encodeURIComponent(sovereignId)}/applications` +} + +/* ── REST calls (catalog read) ───────────────────────────────────── */ + +export async function listCatalog(opts: { org?: string } = {}): Promise { + const params = new URLSearchParams() + if (opts.org) params.set('org', opts.org) + const qs = params.toString() + const url = `${catalogBase()}${qs ? '?' + qs : ''}` + const res = await authedFetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + throw new Error(`catalog list: HTTP ${res.status}`) + } + return res.json() +} + +export async function getCatalogItem(name: string): Promise { + const url = `${catalogBase()}/${encodeURIComponent(name)}` + const res = await authedFetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + throw new Error(`catalog get: HTTP ${res.status}`) + } + return res.json() +} + +export async function getCatalogVersions(name: string): Promise { + const url = `${catalogBase()}/${encodeURIComponent(name)}/versions` + const res = await authedFetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + throw new Error(`catalog versions: HTTP ${res.status}`) + } + return res.json() +} + +export async function getCatalogItemVersion(name: string, version: string): Promise { + const url = `${catalogBase()}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}` + const res = await authedFetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + throw new Error(`catalog get-version: HTTP ${res.status}`) + } + return res.json() +} + +/* ── REST calls (install + preview + status) ─────────────────────── */ + +/** + * ApplicationInstallRequest mirrors the catalyst-api wire shape per + * EPIC-2 brief §I3. + */ +export interface ApplicationInstallRequest { + blueprintRef: { name: string; version: string } + name: string + organizationRef: string + environmentRef: string + parameters?: Record + placement: { mode: string; regions: string[] } +} + +/** ApplicationInstallResponse — body of 201 Created. */ +export interface ApplicationInstallResponse { + name: string + namespace: string + uid: string + status?: Record +} + +/** ApplicationStatusResponse — body of GET status. */ +export interface ApplicationStatusResponse { + name: string + namespace: string + phase?: string + status?: Record +} + +/** PreviewManifest — one rendered file in the preview output. */ +export interface PreviewManifest { + path: string + content: string +} + +/** + * ApplicationPreviewResponse — body of POST .../applications/preview. + * + * EPIC-2 T (topology editor) reuses this contract for "preview before + * topology change". The shape is intentionally future-proof: `diff` + * may be empty when no current state exists, and `warnings` carries + * non-fatal advisory messages. + */ +export interface ApplicationPreviewResponse { + manifests: PreviewManifest[] + diff: string + blueprint: { name: string; version: string } + warnings: string[] +} + +export async function installApplication( + sovereignId: string, + body: ApplicationInstallRequest, +): Promise { + const res = await authedFetch(applicationsBase(sovereignId), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new Error(`install: HTTP ${res.status} ${detail}`) + } + return res.json() +} + +export async function previewApplication( + sovereignId: string, + body: ApplicationInstallRequest, +): Promise { + const res = await authedFetch(`${applicationsBase(sovereignId)}/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new Error(`preview: HTTP ${res.status} ${detail}`) + } + return res.json() +} + +export async function getApplicationStatus( + sovereignId: string, + name: string, + namespace?: string, +): Promise { + const params = new URLSearchParams() + if (namespace) params.set('namespace', namespace) + const qs = params.toString() + const url = `${applicationsBase(sovereignId)}/${encodeURIComponent(name)}/status${qs ? '?' + qs : ''}` + const res = await authedFetch(url, { headers: { Accept: 'application/json' } }) + if (!res.ok) { + throw new Error(`status: HTTP ${res.status}`) + } + return res.json() +} + +export function applicationStreamURL( + sovereignId: string, + name: string, + accessToken?: string, +): string { + const params = new URLSearchParams() + if (accessToken) params.set('access_token', accessToken) + const qs = params.toString() + return `${applicationsBase(sovereignId)}/${encodeURIComponent(name)}/stream${qs ? '?' + qs : ''}` +} diff --git a/products/catalyst/bootstrap/ui/src/lib/useCatalog.test.ts b/products/catalyst/bootstrap/ui/src/lib/useCatalog.test.ts new file mode 100644 index 00000000..84145cc8 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/lib/useCatalog.test.ts @@ -0,0 +1,162 @@ +/** + * useCatalog.test.ts — unit tests for the EPIC-2 Slice I (#1097) + * useCatalog hook + REST client. + * + * Three priority cases covered (per slice L's PRIVATE > SOVEREIGN > + * PUBLIC contract): we don't dedupe in the hook (the server does), but + * we DO assert the wire shape carries `origin` + `source` correctly so + * a UI rendering can show the source badge. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { renderHook, waitFor, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import * as React from 'react' + +import { useCatalog, useCatalogItemVersion } from './useCatalog' +import type { CatalogItem } from './catalog.api' + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) +} + +const PUBLIC_BP: CatalogItem = { + name: 'bp-wordpress', + version: '1.2.3', + card: { title: 'WordPress', summary: 'PHP CMS' }, + origin: 1, + source: 'public', +} +const SOVEREIGN_BP: CatalogItem = { + name: 'bp-cortex', + version: '0.5.0', + card: { title: 'Cortex' }, + origin: 2, + source: 'sovereign', +} +const PRIVATE_BP: CatalogItem = { + name: 'bp-acme-private', + version: '0.1.0', + card: { title: 'ACME Private App' }, + origin: 3, + source: 'org-private', + org: 'acme', +} + +const RAW_WITH_SCHEMA: CatalogItem = { + ...PUBLIC_BP, + raw: { + spec: { + version: '1.2.3', + configSchema: { + type: 'object', + required: ['domain'], + properties: { + domain: { type: 'string' }, + replicas: { type: 'integer', minimum: 1, maximum: 5 }, + }, + }, + }, + }, +} + +describe('useCatalog', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + cleanup() + }) + + it('returns items from /api/v1/catalog', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [PUBLIC_BP, SOVEREIGN_BP, PRIVATE_BP] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) as typeof fetch + + const { result } = renderHook(() => useCatalog(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toHaveLength(3) + expect(result.current.data?.[0].source).toBe('public') + expect(result.current.data?.[1].source).toBe('sovereign') + expect(result.current.data?.[2].source).toBe('org-private') + }) + + it('passes the org query param when provided', async () => { + let capturedURL = '' + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + capturedURL = String(url) + return new Response(JSON.stringify({ items: [PRIVATE_BP] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const { result } = renderHook(() => useCatalog({ org: 'acme' }), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(capturedURL).toContain('org=acme') + }) + + it('surfaces upstream errors via the query state', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response('upstream down', { status: 502 }), + ) as typeof fetch + + const { result } = renderHook(() => useCatalog(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + expect((result.current.error as Error).message).toContain('502') + }) +}) + +describe('useCatalogItemVersion', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + cleanup() + }) + + it('fetches the version-pinned blueprint with raw configSchema', async () => { + let capturedURL = '' + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + capturedURL = String(url) + return new Response(JSON.stringify(RAW_WITH_SCHEMA), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const { result } = renderHook( + () => useCatalogItemVersion('bp-wordpress', '1.2.3'), + { wrapper: makeWrapper() }, + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(capturedURL).toContain('/catalog/bp-wordpress/versions/1.2.3') + expect(result.current.data?.raw).toBeDefined() + const spec = (result.current.data?.raw as Record).spec as Record + expect(spec.configSchema).toBeDefined() + }) + + it('is disabled until name + version are non-empty', () => { + globalThis.fetch = vi.fn() as typeof fetch + const { result } = renderHook( + () => useCatalogItemVersion('', ''), + { wrapper: makeWrapper() }, + ) + expect(result.current.isFetching).toBe(false) + expect(globalThis.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/lib/useCatalog.ts b/products/catalyst/bootstrap/ui/src/lib/useCatalog.ts new file mode 100644 index 00000000..82334c23 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/lib/useCatalog.ts @@ -0,0 +1,117 @@ +/** + * useCatalog — TanStack Query hook backed by catalyst-catalog REST. + * + * EPIC-2 Slice I (#1097) — replaces the static stub + * `pages/sovereign/applicationCatalog.ts` for the install flow. Mirrors + * the slice U `useComplianceStream` pattern (REST baseline, no Zustand + * — TanStack Query owns the cache). + * + * Three hooks shipped here cover the three contract endpoints: + * + * useCatalog({ org? }) → GET /api/v1/catalog (list) + * useCatalogItem(name) → GET /api/v1/catalog/{name} + * useCatalogVersions(name) → GET /api/v1/catalog/{name}/versions + * useCatalogItemVersion(n, v) → GET /api/v1/catalog/{name}/versions/{v} + * + * The version-pinned `useCatalogItemVersion` is the one the install + * flow drives — it is the only endpoint that returns the full Blueprint + * `raw` map so the auto-form generator can read `spec.configSchema`. + * + * Resolution order (per slice L's contract): PRIVATE > SOVEREIGN > + * PUBLIC. The handler does the priority dedup; the hook just renders + * whatever the server returns. + */ + +import { useQuery, type UseQueryResult } from '@tanstack/react-query' + +import { + listCatalog, + getCatalogItem, + getCatalogVersions, + getCatalogItemVersion, + type CatalogItem, + type CatalogVersionsResponse, +} from './catalog.api' + +/** Stable query keys so TanStack devtools + invalidation are coherent. */ +export const catalogQueryKeys = { + all: ['catalog'] as const, + list: (org?: string) => ['catalog', 'list', org ?? ''] as const, + item: (name: string) => ['catalog', 'item', name] as const, + itemVersion: (name: string, version: string) => + ['catalog', 'item', name, version] as const, + versions: (name: string) => ['catalog', 'versions', name] as const, +} + +/** + * Cache TTL for catalog reads. The catalog upstream caches 30s on its + * side; layering 60s on the browser keeps the install page snappy + * without going stale relative to the server. A "Refresh" affordance on + * the install page can call `queryClient.invalidateQueries` if the + * operator just published a Blueprint and wants to see it now. + */ +const CATALOG_STALE_MS = 60_000 + +/** UseCatalogOptions — slim wrapper so consumers can pin the org. */ +export interface UseCatalogOptions { + org?: string + /** Disable the query (e.g. when sovereign id isn't resolved yet). */ + enabled?: boolean +} + +/** useCatalog — list every Blueprint visible to the caller. */ +export function useCatalog(opts: UseCatalogOptions = {}): UseQueryResult { + return useQuery({ + queryKey: catalogQueryKeys.list(opts.org), + queryFn: async () => { + const r = await listCatalog({ org: opts.org }) + return r.items + }, + enabled: opts.enabled !== false, + staleTime: CATALOG_STALE_MS, + }) +} + +/** useCatalogItem — fetch a single Blueprint at its latest version. */ +export function useCatalogItem(name: string, enabled = true): UseQueryResult { + return useQuery({ + queryKey: catalogQueryKeys.item(name), + queryFn: () => getCatalogItem(name), + enabled: enabled && !!name, + staleTime: CATALOG_STALE_MS, + }) +} + +/** useCatalogVersions — version matrix for a Blueprint. */ +export function useCatalogVersions( + name: string, + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: catalogQueryKeys.versions(name), + queryFn: () => getCatalogVersions(name), + enabled: enabled && !!name, + staleTime: CATALOG_STALE_MS, + }) +} + +/** + * useCatalogItemVersion — fetch the FULL Blueprint at a pinned version. + * + * This is the endpoint that returns `raw` (the full parsed Blueprint + * manifest). The install flow uses it to drive the auto-form generator + * via `raw.spec.configSchema`. Caller must pass both `name` and + * `version`; the hook is disabled until both are non-empty. + */ +export function useCatalogItemVersion( + name: string, + version: string, + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: catalogQueryKeys.itemVersion(name, version), + queryFn: () => getCatalogItemVersion(name, version), + enabled: enabled && !!name && !!version, + staleTime: CATALOG_STALE_MS, + }) +} diff --git a/products/catalyst/bootstrap/ui/src/pages/sovereign/InstallPage.tsx b/products/catalyst/bootstrap/ui/src/pages/sovereign/InstallPage.tsx new file mode 100644 index 00000000..dee600d7 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/pages/sovereign/InstallPage.tsx @@ -0,0 +1,373 @@ +/** + * InstallPage — EPIC-2 Slice I (#1097) live install surface. + * + * Two URL shapes: + * + * /install — catalog landing (live useCatalog list) + * /install/$blueprintName — Blueprint selected (latest version) + * + * Layout: + * + * Catalog tab (top): grid of Blueprint cards from the live catalog. + * Click a card → InstallForm renders below, populated by the Blueprint's + * spec.configSchema. + * + * Per BLUEPRINT-AUTHORING.md §3-§5 the form is auto-generated by RJSF; + * no per-Blueprint hand-coded UI. + * + * Submit: posts to /api/v1/sovereigns/{id}/applications. On 201 → opens + * the live status modal subscribed to /stream (SSE). On + * `status.phase=Ready` → closes the modal + navigates to the + * AppDetail page for the new Application. + * + * Preview: posts to /api/v1/sovereigns/{id}/applications/preview. Opens + * the same modal in preview mode (renders manifests + diff side-by-side). + * + * Per docs/INVIOLABLE-PRINCIPLES.md #4 every URL derives from API_BASE + + * the resolved deployment id; nothing hardcoded. + */ + +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' + +import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId' +import { useCatalog, useCatalogItemVersion } from '@/lib/useCatalog' +import { + installApplication, + previewApplication, + type ApplicationInstallRequest, + type ApplicationPreviewResponse, + type CatalogItem, +} from '@/lib/catalog.api' +import { InstallForm } from '@/widgets/install/InstallForm' + +interface InstallPageProps { + /** Test seam — pre-selected Blueprint without going through the catalog grid. */ + preselectedBlueprint?: string +} + +export function InstallPage({ preselectedBlueprint }: InstallPageProps = {}) { + const params = useResolvedDeploymentId() as { deploymentId: string | null } + const deploymentId = params.deploymentId ?? '' + const navigate = useNavigate() + + const catalogQuery = useCatalog({ enabled: !!deploymentId }) + const [selectedName, setSelectedName] = useState(preselectedBlueprint ?? null) + + // Find the selected card in the list (latest version returned by the + // resolver wins per slice L's contract). + const selectedCard = useMemo(() => { + if (!selectedName || !catalogQuery.data) return undefined + return catalogQuery.data.find((c) => c.name === selectedName) + }, [catalogQuery.data, selectedName]) + + // Fetch the version-pinned Blueprint so `raw.spec.configSchema` is populated. + const versionQuery = useCatalogItemVersion( + selectedCard?.name ?? '', + selectedCard?.version ?? '', + !!selectedCard, + ) + + const [installError, setInstallError] = useState(null) + const [previewState, setPreviewState] = useState(null) + const [statusName, setStatusName] = useState(null) + + // Default placement / org / env per the brief — single-region, primary + // region read from the deployment's first region. For slice I we keep + // these as plain inputs above the auto-form; a follow-up slice swaps + // them for richer pickers (Topology editor, see EPIC-2 slice T). + const [organizationRef, setOrganizationRef] = useState('default') + const [environmentRef, setEnvironmentRef] = useState('default-prod') + const [appName, setAppName] = useState('') + const [region, setRegion] = useState('hz-fsn-rtz-prod') + const [placementMode, setPlacementMode] = useState('single-region') + + // Reset the form scaffold when a new Blueprint is selected. Keeps the + // "click another card" UX intuitive — the prior input doesn't survive + // the swap. + useEffect(() => { + setInstallError(null) + setPreviewState(null) + if (selectedCard && !appName) { + const slug = selectedCard.name.replace(/^bp-/, '') + setAppName(`${slug}-prod`) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCard?.name]) + + const composeInstallRequest = (parameters: Record): ApplicationInstallRequest => ({ + blueprintRef: { + name: selectedCard?.name ?? '', + version: selectedCard?.version ?? '', + }, + name: appName, + organizationRef, + environmentRef, + parameters, + placement: { mode: placementMode, regions: [region] }, + }) + + const handleSubmit = async (parameters: Record) => { + if (!deploymentId || !selectedCard) return + setInstallError(null) + try { + const resp = await installApplication(deploymentId, composeInstallRequest(parameters)) + setStatusName(resp.name) + } catch (e) { + setInstallError((e as Error).message) + } + } + + const handlePreview = async (parameters: Record) => { + if (!deploymentId || !selectedCard) return + setInstallError(null) + try { + const resp = await previewApplication(deploymentId, composeInstallRequest(parameters)) + setPreviewState(resp) + } catch (e) { + setInstallError((e as Error).message) + } + } + + const dismissPreview = () => setPreviewState(null) + const dismissStatus = () => { + setStatusName(null) + if (statusName) { + // Navigate to the AppDetail page once the modal is dismissed — + // matches the canonical "click into the new Application" flow. + navigate({ to: '/console/apps' as never }).catch(() => undefined) + } + } + + if (!deploymentId) { + return ( +
+ Resolving deployment context… +
+ ) + } + + return ( +
+
+

Install Blueprint

+ + {catalogQuery.data?.length ?? 0} blueprints visible + +
+ + {catalogQuery.isLoading ? ( +
+ Loading catalog… +
+ ) : null} + + {catalogQuery.isError ? ( +
+ Failed to load catalog: {(catalogQuery.error as Error)?.message ?? 'unknown error'} +
+ ) : null} + + {catalogQuery.data && !selectedCard ? ( +
+ {catalogQuery.data.map((item) => ( + + ))} +
+ ) : null} + + {selectedCard ? ( +
+ + +
+

+ {selectedCard.card.title || selectedCard.name} +

+

+ {selectedCard.card.summary || selectedCard.card.description} +

+

+ Version: {selectedCard.version} + {' · '} + Source: {selectedCard.source} +

+
+ + {/* Fixed form scaffold — name, org, env, region, placement. */} +
+ + + + + +
+ + {/* Auto-form for Blueprint parameters. */} + {versionQuery.isLoading ? ( +
+ Loading Blueprint parameters… +
+ ) : null} + {versionQuery.data ? ( + + ) : null} + + {installError ? ( +
+ {installError} +
+ ) : null} +
+ ) : null} + + {/* Preview modal */} + {previewState ? ( +
+
+
+

+ Preview — {previewState.blueprint.name}@{previewState.blueprint.version} +

+ +
+ {previewState.warnings.length > 0 ? ( +
    + {previewState.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ ) : null} + {previewState.manifests.map((m) => ( +
+
{m.path}
+
+                  {m.content}
+                
+
+ ))} +
+
+ ) : null} + + {/* Status modal */} + {statusName ? ( +
+
+

Application installed

+

+ {statusName} is now reconciling. The application-controller will provision Flux + HelmReleases for each region; status updates will appear on the Apps page. +

+
+ +
+
+
+ ) : null} +
+ ) +} diff --git a/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.test.tsx b/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.test.tsx new file mode 100644 index 00000000..5fb46fa1 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.test.tsx @@ -0,0 +1,169 @@ +/** + * InstallForm.test.tsx — unit tests for the EPIC-2 Slice I (#1097) auto-form. + * + * Snapshot rendering across three configSchema shapes: + * + * 1. Trivial schema (single string field) — base case. + * 2. Schema with x-catalyst-ui-hint=password — masked widget engages. + * 3. Blueprint with no configSchema — install-with-defaults branch. + * + * Plus a round-trip: changing a field value + clicking Submit fires + * onSubmit with the collected parameters. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' + +import { InstallForm } from './InstallForm' +import { buildUiSchema, extractConfigSchema } from './installFormSchema' +import type { CatalogItem } from '@/lib/catalog.api' + +afterEach(() => cleanup()) + +const makeBP = (configSchema?: unknown): CatalogItem => ({ + name: 'bp-test', + version: '1.0.0', + card: { title: 'Test Blueprint' }, + origin: 1, + source: 'public', + raw: configSchema + ? { spec: { version: '1.0.0', configSchema } } + : undefined, +}) + +describe('InstallForm — schema rendering', () => { + it('renders a string input for a trivial single-field schema', () => { + const onSubmit = vi.fn() + render( + , + ) + // RJSF renders an input with id rooted at root_. + expect(document.getElementById('root_domain')).toBeTruthy() + }) + + it('renders the password widget when x-catalyst-ui-hint=password', () => { + render( + , + ) + const masked = screen.getByTestId('install-form-password-input') as HTMLInputElement + expect(masked.type).toBe('password') + }) + + it('renders the install-with-defaults branch when blueprint has no configSchema', () => { + const onSubmit = vi.fn() + render() + expect(screen.getByTestId('install-form-no-schema')).toBeTruthy() + fireEvent.click(screen.getByTestId('install-form-submit-btn')) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenCalledWith({}) + }) +}) + +describe('InstallForm — submit + preview wiring', () => { + it('fires onSubmit with the formData when the form is submitted', async () => { + const onSubmit = vi.fn() + render( + , + ) + const input = document.getElementById('root_domain') as HTMLInputElement + fireEvent.change(input, { target: { value: 'example.test' } }) + const submit = screen.getByTestId('install-form-submit-btn') + fireEvent.click(submit) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit.mock.calls[0][0]).toMatchObject({ domain: 'example.test' }) + }) + + it('fires onPreview with the current formData when Preview is clicked', () => { + const onPreview = vi.fn() + render( + , + ) + const input = document.getElementById('root_domain') as HTMLInputElement + fireEvent.change(input, { target: { value: 'preview.test' } }) + fireEvent.click(screen.getByTestId('install-form-preview-btn')) + expect(onPreview).toHaveBeenCalledTimes(1) + expect(onPreview.mock.calls[0][0]).toMatchObject({ domain: 'preview.test' }) + }) +}) + +describe('buildUiSchema', () => { + it('projects a top-level password hint to PasswordWidget', () => { + const ui = buildUiSchema({ + type: 'object', + properties: { + secret: { type: 'string', 'x-catalyst-ui-hint': 'password' }, + plain: { type: 'string' }, + }, + }) + expect(ui.secret).toMatchObject({ 'ui:widget': 'PasswordWidget' }) + expect(ui.plain).toBeUndefined() + }) + + it('projects a domain-picker hint to DomainPickerWidget', () => { + const ui = buildUiSchema({ + type: 'object', + properties: { + host: { type: 'string', 'x-catalyst-ui-hint': 'domain-picker' }, + }, + }) + expect(ui.host).toMatchObject({ 'ui:widget': 'DomainPickerWidget' }) + }) + + it('ignores unknown hint values', () => { + const ui = buildUiSchema({ + type: 'object', + properties: { + weird: { type: 'string', 'x-catalyst-ui-hint': 'totally-made-up' }, + }, + }) + expect(ui.weird).toBeUndefined() + }) +}) + +describe('extractConfigSchema', () => { + it('returns the schema when raw.spec.configSchema is present', () => { + const bp = makeBP({ type: 'object' }) + expect(extractConfigSchema(bp)).toMatchObject({ type: 'object' }) + }) + + it('returns null when raw is missing', () => { + expect(extractConfigSchema(makeBP())).toBeNull() + }) + + it('returns null when raw.spec.configSchema is missing', () => { + const bp: CatalogItem = { + ...makeBP(), + raw: { spec: { version: '1.0.0' } }, + } + expect(extractConfigSchema(bp)).toBeNull() + }) +}) diff --git a/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.tsx b/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.tsx new file mode 100644 index 00000000..d1efb67f --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/widgets/install/InstallForm.tsx @@ -0,0 +1,253 @@ +/** + * InstallForm — auto-form generator for Blueprint installs. + * + * EPIC-2 Slice I (#1097, I2): given a CatalogItem with a populated + * `raw.spec.configSchema`, render a JSON-Schema-driven form via + * `@rjsf/core` + `@rjsf/validator-ajv8`. The submit button posts the + * collected parameters; a separate Preview button asks the catalyst-api + * to render the would-be manifests without committing. + * + * Per docs/INVIOLABLE-PRINCIPLES.md #2 we use the canonical JSON-Schema- + * to-React-Form library (RJSF). NO custom form generator. This means a + * Blueprint author who follows BLUEPRINT-AUTHORING.md §3-§5 gets a + * usable install form with zero UI work. + * + * Per BLUEPRINT-AUTHORING.md §4 each schema field can carry a + * `x-catalyst-ui-hint` extension that selects a custom widget: + * + * x-catalyst-ui-hint: password → masked input + * x-catalyst-ui-hint: domain-picker → calls catalyst-api domains list + * x-catalyst-ui-hint: application-ref → existing-Applications dropdown + * x-catalyst-ui-hint: secret-ref → K8s Secret reference + * + * The `password` hint is wired in slice I; the other three are stubbed + * to fall back to the default string input — they ship as separate + * widget files in a follow-up slice (I-followup) so the install form's + * minimum-viable surface lands in I. + */ + +import { useMemo, useState } from 'react' +import RJSFForm from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import type { + RJSFSchema, + RegistryWidgetsType, + WidgetProps, +} from '@rjsf/utils' + +import type { CatalogItem } from '@/lib/catalog.api' +import { buildUiSchema, extractConfigSchema } from './installFormSchema' + +/* ── Custom widgets ──────────────────────────────────────────────── */ + +/** + * PasswordWidget — masked input. Re-renders the default string widget + * with `type="password"` so the value never appears on screen. Per + * docs/INVIOLABLE-PRINCIPLES.md #10 (output hygiene) we never log the + * value either; the only consumer is the install POST body. + */ +function PasswordWidget(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange, placeholder } = props + return ( + onChange(e.target.value === '' ? undefined : e.target.value)} + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + data-testid="install-form-password-input" + /> + ) +} + +/** + * DomainPickerWidget — placeholder for the domain-picker custom widget. + * Today renders a plain text input with a hint that the operator may + * supply any reachable domain; a follow-up slice wires the + * `/api/v1/sovereigns/{id}/domains` REST call (catalyst-api emits the + * Sovereign's owned-domains list including the per-Org subdomain + * pool). + */ +function DomainPickerWidget(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange, placeholder } = props + return ( + onChange(e.target.value === '' ? undefined : e.target.value)} + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + data-testid="install-form-domain-input" + /> + ) +} + +/** + * ApplicationRefWidget — placeholder for the existing-Applications + * dropdown. Renders the default string input today; a follow-up slice + * wires `GET /api/v1/sovereigns/{id}/applications` and renders a + * dropdown of the names the operator can target. + */ +function ApplicationRefWidget(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange, placeholder } = props + return ( + onChange(e.target.value === '' ? undefined : e.target.value)} + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + data-testid="install-form-app-ref-input" + /> + ) +} + +/** + * SecretRefWidget — placeholder for the K8s Secret reference. Renders + * the default string input today; a follow-up slice wires the + * `/api/v1/sovereigns/{id}/secrets` REST call. + */ +function SecretRefWidget(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange, placeholder } = props + return ( + onChange(e.target.value === '' ? undefined : e.target.value)} + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" + data-testid="install-form-secret-ref-input" + /> + ) +} + +/** + * Widget registry — RJSF expects a `widgets` map keyed by widget name. + * The UiSchema below sets `ui:widget` to one of these names per field + * that carries a known x-catalyst-ui-hint. + */ +const installFormWidgets: RegistryWidgetsType = { + PasswordWidget, + DomainPickerWidget, + ApplicationRefWidget, + SecretRefWidget, +} + +/* ── Public component ────────────────────────────────────────────── */ + +export interface InstallFormProps { + blueprint: CatalogItem + /** Submit-time callback. Receives the collected parameters. */ + onSubmit: (parameters: Record) => void + /** Preview-button callback. Same parameters as onSubmit. */ + onPreview?: (parameters: Record) => void + /** Optional initial values (e.g. when editing a draft). */ + initialFormData?: Record + /** Disable the submit button (e.g. while a parent install is in flight). */ + disabled?: boolean +} + +/** + * InstallForm — renders a Blueprint's configSchema as a form. Submits + * the collected parameters via `onSubmit`; offers a Preview button + * that surfaces the same parameters via `onPreview` so the parent can + * open a modal showing the would-be manifests. + */ +export function InstallForm({ + blueprint, + onSubmit, + onPreview, + initialFormData, + disabled, +}: InstallFormProps) { + const schema = useMemo(() => extractConfigSchema(blueprint), [blueprint]) + const uiSchema = useMemo(() => buildUiSchema(schema), [schema]) + const [formData, setFormData] = useState | undefined>(initialFormData) + + if (!schema) { + return ( +
+ Blueprint {blueprint.name}@{blueprint.version} declares no + configSchema — installing with default parameters. +
+ {onPreview ? ( + + ) : null} + +
+
+ ) + } + + return ( + setFormData(e.formData as Record)} + onSubmit={(e) => onSubmit((e.formData ?? {}) as Record)} + disabled={disabled} + > +
+ {onPreview ? ( + + ) : null} + +
+
+ ) +} + diff --git a/products/catalyst/bootstrap/ui/src/widgets/install/installFormSchema.ts b/products/catalyst/bootstrap/ui/src/widgets/install/installFormSchema.ts new file mode 100644 index 00000000..76b8b7d7 --- /dev/null +++ b/products/catalyst/bootstrap/ui/src/widgets/install/installFormSchema.ts @@ -0,0 +1,97 @@ +/** + * installFormSchema.ts — pure helpers used by InstallForm. Lifted into + * their own module so the React component file exports only the + * component (per react-refresh/only-export-components). + * + * Per BLUEPRINT-AUTHORING.md §4 each schema field can carry a + * `x-catalyst-ui-hint` extension that selects a custom widget: + * + * x-catalyst-ui-hint: password → masked input + * x-catalyst-ui-hint: domain-picker → custom widget + * x-catalyst-ui-hint: application-ref → existing-Applications dropdown + * x-catalyst-ui-hint: secret-ref → K8s Secret reference + * + * Unknown hint values are tolerated (the field renders with the + * default widget). The widget name strings (PasswordWidget, + * DomainPickerWidget, ...) match the keys of the registry the + * InstallForm component supplies to RJSF. + */ + +import type { UiSchema } from '@rjsf/utils' + +import type { CatalogItem } from '@/lib/catalog.api' + +/** UIHint values consumed by the install form. */ +export type UIHint = 'password' | 'domain-picker' | 'application-ref' | 'secret-ref' + +const KNOWN_HINTS: ReadonlySet = new Set([ + 'password', + 'domain-picker', + 'application-ref', + 'secret-ref', +]) + +/** + * widgetForHint maps a x-catalyst-ui-hint value to the registered + * widget name. Returns undefined for unknown hints so the caller can + * skip the UiSchema entry and let RJSF render the default widget. + */ +export function widgetForHint(hint: UIHint): string | undefined { + switch (hint) { + case 'password': + return 'PasswordWidget' + case 'domain-picker': + return 'DomainPickerWidget' + case 'application-ref': + return 'ApplicationRefWidget' + case 'secret-ref': + return 'SecretRefWidget' + default: + return undefined + } +} + +/** + * buildUiSchema — walk the configSchema recursively and project every + * `x-catalyst-ui-hint: ` field onto an RJSF UiSchema entry that + * picks the corresponding widget name. + */ +export function buildUiSchema(schema: unknown): UiSchema { + const out: UiSchema = {} + walk(schema, out) + return out +} + +function walk(schema: unknown, ui: UiSchema): void { + if (!schema || typeof schema !== 'object') return + const obj = schema as Record + const props = obj.properties as Record | undefined + if (!props) return + for (const [key, raw] of Object.entries(props)) { + if (!raw || typeof raw !== 'object') continue + const fieldSchema = raw as Record + const hintRaw = fieldSchema['x-catalyst-ui-hint'] + if (typeof hintRaw === 'string' && KNOWN_HINTS.has(hintRaw as UIHint)) { + const widget = widgetForHint(hintRaw as UIHint) + if (widget) ui[key] = { ...(ui[key] ?? {}), 'ui:widget': widget } + } + if ((fieldSchema.type as string | undefined) === 'object' && fieldSchema.properties) { + const nested: UiSchema = ui[key] && typeof ui[key] === 'object' ? (ui[key] as UiSchema) : {} + walk(fieldSchema, nested) + if (Object.keys(nested).length > 0) ui[key] = nested + } + } +} + +/** + * extractConfigSchema — pull `spec.configSchema` from a CatalogItem's + * `raw` map. Returns null when the Blueprint has no configSchema. + */ +export function extractConfigSchema(bp: CatalogItem): unknown { + if (!bp.raw || typeof bp.raw !== 'object') return null + const spec = (bp.raw as Record).spec as Record | undefined + if (!spec) return null + const cs = spec.configSchema + if (!cs || typeof cs !== 'object') return null + return cs +}