feat(catalyst-ui): live install flow — useCatalog + InstallForm + /applications + preview (slice I, #1097) (#1152)

EPIC-2 Slice I: replaces the static applicationCatalog stub with a
live install flow driven by catalyst-catalog (slice L, #1148).

UI:
- src/lib/catalog.api.ts — typed REST client to catalyst-api proxy.
- src/lib/useCatalog.ts — TanStack Query hooks (list, item, version,
  versions). Mirrors the slice U useComplianceStream pattern (REST
  baseline; no Zustand).
- src/widgets/install/InstallForm.tsx — auto-form generator backed by
  @rjsf/core + @rjsf/validator-ajv8. Honors x-catalyst-ui-hint
  extensions per BLUEPRINT-AUTHORING.md §4: password (masked input),
  domain-picker, application-ref, secret-ref. Unknown hints fall back
  to the default RJSF widget.
- src/widgets/install/installFormSchema.ts — pure helpers (buildUiSchema,
  extractConfigSchema) lifted out so the component module exports only
  components (react-refresh/only-export-components).
- src/pages/sovereign/InstallPage.tsx — catalog grid → form → submit
  with preview button + status modal.
- Routes: /provision/$deploymentId/install (mothership tree) and
  /install (chroot consoleLayoutRoute), each with a $blueprintName
  variant for deep-linking.

Server (catalyst-api):
- internal/handler/catalog_client.go — narrow REST client to
  catalyst-catalog. CATALYST_CATALOG_URL is env-overridable
  (INVIOLABLE-PRINCIPLES #4); defaults to the in-cluster service FQDN.
- internal/handler/applications.go — POST /applications creates the
  Application CR per ADR-0001 §2.7. Validates parameters against
  Blueprint.spec.configSchema using core/controllers/pkg/validate
  (santhosh-tekuri/jsonschema/v5). 201/400/403/404/409/503 surface
  the canonical error vocabulary the UI status modal renders.
- internal/handler/applications_preview.go — POST .../preview renders
  manifests via core/controllers/pkg/render. Pure simulation (no CR
  write, no Gitea commit). Response shape is forward-compatible with
  EPIC-2 T topology preview.
- GET .../applications/{name}/status (snapshot) and .../stream (SSE).
- Route registration in cmd/api/main.go; catalogClient wired from env
  unconditionally (handlers surface 502/503 with detail when upstream
  fails).
- internal/handler/applications_test.go — 9 paths: 201 happy, 400
  invalid params (configSchema), 400 missing field, 403 unauthorized,
  404 unknown blueprint, 409 duplicate, 503 unwired catalog, 502
  upstream error, status 200/404, preview 200/400.

Promoted packages (per slice L's pattern with the Gitea client):
- core/controllers/internal/render → core/controllers/pkg/render.
- core/controllers/application/internal/validate →
  core/controllers/pkg/validate.
- products/catalyst/bootstrap/api/go.mod adds a `replace` directive
  pinning to the in-tree controllers module so the renderer the
  preview emits is byte-identical to the one application-controller
  ships at install time.

Tests:
- Vitest: 5 useCatalog tests, 11 InstallForm tests (16 passed).
- Playwright (5 snapshots @ 1440x900): I1 catalog grid, I2 form +
  password mask, I3 submit + status modal, I4 preview modal, I5
  install-with-defaults branch.
- go test -count=1 -race ./... clean across both modules.

Per per-issue-Playwright-verification rule: 5 snapshots in
playwright-report/install-i{1..5}-*.png, one per issue surface.

Co-authored-by: hatiyildiz <269457768+hatiyildiz@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-09 05:19:50 +04:00 committed by GitHub
parent 746901b671
commit d5284d7289
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 3996 additions and 12 deletions

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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=

View File

@ -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=<org>` 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: <statusJSON>\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-<slug>", 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
}

View File

@ -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 "<blueprint>-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/<region>/applications/<app>/kustomization.yaml",
// "content": "..." },
// { "path": "clusters/<region>/applications/<app>/helmrelease.yaml",
// "content": "..." }
// ],
// "diff": "<unified-diff-when-the-target-already-exists-OR-empty>",
// "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
// `<org>/<app>` 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-<slug>", 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"
}

View File

@ -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

View File

@ -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=<slug>` 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
// <tok>` and `Cookie: catalyst_session=<tok>` 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 }

View File

@ -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

View File

@ -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,
})
})
})

View File

@ -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",

View File

@ -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",

View File

@ -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 <InstallPage preselectedBlueprint={blueprintName} />
},
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 <InstallPage preselectedBlueprint={blueprintName} />
},
})
// /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,

View File

@ -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<string, unknown>
}
export interface CatalogListResponse {
items: CatalogItem[]
}
export interface CatalogVersionsResponse {
name: string
versions: { version: string; origin: string; org?: string }[]
upgradeMatrix: Record<string, string[]>
}
/* ── 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<CatalogListResponse> {
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<CatalogItem> {
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<CatalogVersionsResponse> {
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<CatalogItem> {
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<string, unknown>
placement: { mode: string; regions: string[] }
}
/** ApplicationInstallResponse — body of 201 Created. */
export interface ApplicationInstallResponse {
name: string
namespace: string
uid: string
status?: Record<string, unknown>
}
/** ApplicationStatusResponse — body of GET status. */
export interface ApplicationStatusResponse {
name: string
namespace: string
phase?: string
status?: Record<string, unknown>
}
/** 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<ApplicationInstallResponse> {
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<ApplicationPreviewResponse> {
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<ApplicationStatusResponse> {
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 : ''}`
}

View File

@ -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<string, unknown>).spec as Record<string, unknown>
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()
})
})

View File

@ -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<CatalogItem[]> {
return useQuery<CatalogItem[]>({
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<CatalogItem> {
return useQuery<CatalogItem>({
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<CatalogVersionsResponse> {
return useQuery<CatalogVersionsResponse>({
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<CatalogItem> {
return useQuery<CatalogItem>({
queryKey: catalogQueryKeys.itemVersion(name, version),
queryFn: () => getCatalogItemVersion(name, version),
enabled: enabled && !!name && !!version,
staleTime: CATALOG_STALE_MS,
})
}

View File

@ -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<string | null>(preselectedBlueprint ?? null)
// Find the selected card in the list (latest version returned by the
// resolver wins per slice L's contract).
const selectedCard = useMemo<CatalogItem | undefined>(() => {
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<string | null>(null)
const [previewState, setPreviewState] = useState<ApplicationPreviewResponse | null>(null)
const [statusName, setStatusName] = useState<string | null>(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<string>('default')
const [environmentRef, setEnvironmentRef] = useState<string>('default-prod')
const [appName, setAppName] = useState<string>('')
const [region, setRegion] = useState<string>('hz-fsn-rtz-prod')
const [placementMode, setPlacementMode] = useState<string>('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<string, unknown>): ApplicationInstallRequest => ({
blueprintRef: {
name: selectedCard?.name ?? '',
version: selectedCard?.version ?? '',
},
name: appName,
organizationRef,
environmentRef,
parameters,
placement: { mode: placementMode, regions: [region] },
})
const handleSubmit = async (parameters: Record<string, unknown>) => {
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<string, unknown>) => {
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 (
<div className="p-6 text-sm text-[var(--color-text-dim)]" data-testid="install-page-no-deployment">
Resolving deployment context
</div>
)
}
return (
<div className="p-6" data-testid="install-page">
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-2xl font-semibold text-[var(--color-text)]">Install Blueprint</h1>
<span className="text-sm text-[var(--color-text-dim)]">
{catalogQuery.data?.length ?? 0} blueprints visible
</span>
</div>
{catalogQuery.isLoading ? (
<div className="text-sm text-[var(--color-text-dim)]" data-testid="install-page-loading">
Loading catalog
</div>
) : null}
{catalogQuery.isError ? (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-400" data-testid="install-page-catalog-error">
Failed to load catalog: {(catalogQuery.error as Error)?.message ?? 'unknown error'}
</div>
) : null}
{catalogQuery.data && !selectedCard ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3" data-testid="install-page-catalog-grid">
{catalogQuery.data.map((item) => (
<button
key={`${item.name}@${item.version}`}
type="button"
className="flex flex-col items-start rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-elev)] p-4 text-left transition hover:border-[var(--color-accent)]"
data-testid={`install-page-card-${item.name}`}
onClick={() => setSelectedName(item.name)}
>
<span className="text-sm font-semibold text-[var(--color-text)]">
{item.card.title || item.name}
</span>
<span className="mt-1 text-xs text-[var(--color-text-dim)]">{item.name}@{item.version}</span>
<span className="mt-2 text-xs text-[var(--color-text-dim)]">
{item.card.summary || item.card.description || 'No description.'}
</span>
<span className="mt-3 inline-block rounded bg-[var(--color-accent)]/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-accent)]">
{item.source}
</span>
</button>
))}
</div>
) : null}
{selectedCard ? (
<div data-testid="install-page-form-section">
<button
type="button"
className="mb-3 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
data-testid="install-page-back-btn"
onClick={() => setSelectedName(null)}
>
Back to catalog
</button>
<div className="mb-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-elev)] p-4">
<h2 className="text-lg font-semibold text-[var(--color-text)]">
{selectedCard.card.title || selectedCard.name}
</h2>
<p className="mt-1 text-xs text-[var(--color-text-dim)]">
{selectedCard.card.summary || selectedCard.card.description}
</p>
<p className="mt-2 text-xs text-[var(--color-text-dim)]">
<strong>Version:</strong> {selectedCard.version}
{' · '}
<strong>Source:</strong> {selectedCard.source}
</p>
</div>
{/* Fixed form scaffold — name, org, env, region, placement. */}
<div className="mb-4 grid grid-cols-1 gap-3 rounded-lg border border-[var(--color-border)] p-4 md:grid-cols-2">
<label className="flex flex-col text-xs text-[var(--color-text-dim)]">
Application name
<input
type="text"
className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
data-testid="install-page-app-name"
value={appName}
onChange={(e) => setAppName(e.target.value)}
/>
</label>
<label className="flex flex-col text-xs text-[var(--color-text-dim)]">
Organization
<input
type="text"
className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
data-testid="install-page-org-ref"
value={organizationRef}
onChange={(e) => setOrganizationRef(e.target.value)}
/>
</label>
<label className="flex flex-col text-xs text-[var(--color-text-dim)]">
Environment
<input
type="text"
className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
data-testid="install-page-env-ref"
value={environmentRef}
onChange={(e) => setEnvironmentRef(e.target.value)}
/>
</label>
<label className="flex flex-col text-xs text-[var(--color-text-dim)]">
Region
<input
type="text"
className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
data-testid="install-page-region"
value={region}
onChange={(e) => setRegion(e.target.value)}
/>
</label>
<label className="flex flex-col text-xs text-[var(--color-text-dim)]">
Placement mode
<select
className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
data-testid="install-page-placement"
value={placementMode}
onChange={(e) => setPlacementMode(e.target.value)}
>
<option value="single-region">single-region</option>
<option value="active-active">active-active</option>
<option value="active-hotstandby">active-hotstandby</option>
</select>
</label>
</div>
{/* Auto-form for Blueprint parameters. */}
{versionQuery.isLoading ? (
<div className="text-xs text-[var(--color-text-dim)]" data-testid="install-page-form-loading">
Loading Blueprint parameters
</div>
) : null}
{versionQuery.data ? (
<InstallForm
blueprint={versionQuery.data}
onSubmit={handleSubmit}
onPreview={handlePreview}
/>
) : null}
{installError ? (
<div
className="mt-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-400"
data-testid="install-page-error"
>
{installError}
</div>
) : null}
</div>
) : null}
{/* Preview modal */}
{previewState ? (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
data-testid="install-page-preview-modal"
>
<div className="max-h-[80vh] w-full max-w-3xl overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-5">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-base font-semibold text-[var(--color-text)]">
Preview {previewState.blueprint.name}@{previewState.blueprint.version}
</h3>
<button
type="button"
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
data-testid="install-page-preview-close"
onClick={dismissPreview}
>
Close
</button>
</div>
{previewState.warnings.length > 0 ? (
<ul className="mb-3 list-disc rounded-md border border-yellow-500/40 bg-yellow-500/10 px-4 py-2 pl-6 text-xs text-yellow-300">
{previewState.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
) : null}
{previewState.manifests.map((m) => (
<div key={m.path} className="mb-3" data-testid={`install-page-preview-manifest-${m.path}`}>
<div className="text-xs font-mono text-[var(--color-text-dim)]">{m.path}</div>
<pre className="mt-1 overflow-auto rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev)] p-3 text-[11px] leading-5 text-[var(--color-text)]">
{m.content}
</pre>
</div>
))}
</div>
</div>
) : null}
{/* Status modal */}
{statusName ? (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
data-testid="install-page-status-modal"
>
<div className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-5">
<h3 className="text-base font-semibold text-[var(--color-text)]">Application installed</h3>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
{statusName} is now reconciling. The application-controller will provision Flux
HelmReleases for each region; status updates will appear on the Apps page.
</p>
<div className="mt-4 flex justify-end">
<button
type="button"
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-[var(--color-bg)] hover:opacity-90"
data-testid="install-page-status-close"
onClick={dismissStatus}
>
Open Apps
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -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(
<InstallForm
blueprint={makeBP({
type: 'object',
required: ['domain'],
properties: { domain: { type: 'string' } },
})}
onSubmit={onSubmit}
/>,
)
// RJSF renders an input with id rooted at root_<field>.
expect(document.getElementById('root_domain')).toBeTruthy()
})
it('renders the password widget when x-catalyst-ui-hint=password', () => {
render(
<InstallForm
blueprint={makeBP({
type: 'object',
required: ['secret'],
properties: {
secret: { type: 'string', 'x-catalyst-ui-hint': 'password' },
},
})}
onSubmit={vi.fn()}
/>,
)
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(<InstallForm blueprint={makeBP()} onSubmit={onSubmit} />)
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(
<InstallForm
blueprint={makeBP({
type: 'object',
properties: { domain: { type: 'string' } },
})}
onSubmit={onSubmit}
/>,
)
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(
<InstallForm
blueprint={makeBP({
type: 'object',
properties: { domain: { type: 'string' } },
})}
onSubmit={vi.fn()}
onPreview={onPreview}
/>,
)
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()
})
})

View File

@ -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 (
<input
type="password"
id={id}
autoComplete="new-password"
disabled={disabled}
readOnly={readonly}
required={required}
placeholder={placeholder ?? ''}
value={value ?? ''}
onChange={(e) => 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 (
<input
type="text"
id={id}
disabled={disabled}
readOnly={readonly}
required={required}
placeholder={placeholder ?? 'shop.acme.com'}
value={value ?? ''}
onChange={(e) => 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 (
<input
type="text"
id={id}
disabled={disabled}
readOnly={readonly}
required={required}
placeholder={placeholder ?? 'wp-prod'}
value={value ?? ''}
onChange={(e) => 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 (
<input
type="text"
id={id}
disabled={disabled}
readOnly={readonly}
required={required}
placeholder={placeholder ?? 'my-secret'}
value={value ?? ''}
onChange={(e) => 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<string, unknown>) => void
/** Preview-button callback. Same parameters as onSubmit. */
onPreview?: (parameters: Record<string, unknown>) => void
/** Optional initial values (e.g. when editing a draft). */
initialFormData?: Record<string, unknown>
/** 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<Record<string, unknown> | undefined>(initialFormData)
if (!schema) {
return (
<div
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev)] px-4 py-6 text-sm text-[var(--color-text-dim)]"
data-testid="install-form-no-schema"
>
Blueprint <strong>{blueprint.name}</strong>@{blueprint.version} declares no
configSchema installing with default parameters.
<div className="mt-3 flex gap-2">
{onPreview ? (
<button
type="button"
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs hover:border-[var(--color-accent)]"
data-testid="install-form-preview-btn"
disabled={disabled}
onClick={() => onPreview({})}
>
Preview
</button>
) : null}
<button
type="button"
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-[var(--color-bg)] hover:opacity-90"
data-testid="install-form-submit-btn"
disabled={disabled}
onClick={() => onSubmit({})}
>
Install
</button>
</div>
</div>
)
}
return (
<RJSFForm
schema={schema as RJSFSchema}
uiSchema={uiSchema}
validator={validator}
widgets={installFormWidgets}
formData={formData}
onChange={(e) => setFormData(e.formData as Record<string, unknown>)}
onSubmit={(e) => onSubmit((e.formData ?? {}) as Record<string, unknown>)}
disabled={disabled}
>
<div className="mt-4 flex gap-2" data-testid="install-form-actions">
{onPreview ? (
<button
type="button"
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs hover:border-[var(--color-accent)]"
data-testid="install-form-preview-btn"
disabled={disabled}
onClick={() => onPreview((formData ?? {}) as Record<string, unknown>)}
>
Preview
</button>
) : null}
<button
type="submit"
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-[var(--color-bg)] hover:opacity-90"
data-testid="install-form-submit-btn"
disabled={disabled}
>
Install
</button>
</div>
</RJSFForm>
)
}

View File

@ -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<UIHint> = 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: <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<string, unknown>
const props = obj.properties as Record<string, unknown> | undefined
if (!props) return
for (const [key, raw] of Object.entries(props)) {
if (!raw || typeof raw !== 'object') continue
const fieldSchema = raw as Record<string, unknown>
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<string, unknown>).spec as Record<string, unknown> | undefined
if (!spec) return null
const cs = spec.configSchema
if (!cs || typeof cs !== 'object') return null
return cs
}