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:
parent
746901b671
commit
d5284d7289
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
645
products/catalyst/bootstrap/api/internal/handler/applications.go
Normal file
645
products/catalyst/bootstrap/api/internal/handler/applications.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
@ -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 }
|
||||
@ -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
|
||||
|
||||
280
products/catalyst/bootstrap/ui/e2e/install-flow.spec.ts
Normal file
280
products/catalyst/bootstrap/ui/e2e/install-flow.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
298
products/catalyst/bootstrap/ui/package-lock.json
generated
298
products/catalyst/bootstrap/ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
258
products/catalyst/bootstrap/ui/src/lib/catalog.api.ts
Normal file
258
products/catalyst/bootstrap/ui/src/lib/catalog.api.ts
Normal 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 : ''}`
|
||||
}
|
||||
162
products/catalyst/bootstrap/ui/src/lib/useCatalog.test.ts
Normal file
162
products/catalyst/bootstrap/ui/src/lib/useCatalog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
117
products/catalyst/bootstrap/ui/src/lib/useCatalog.ts
Normal file
117
products/catalyst/bootstrap/ui/src/lib/useCatalog.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user