fix(api): /api/v1/catalog* proxy on catalyst-api (qa-loop iter-3) (#1205)

Sovereign Console at console.<sov> proxies its /api/* fetches through
catalyst-api's ingress, but Slice-L (#1148) only exposed catalyst-catalog
via a Gateway HTTPRoute attached to the api.<sov> hostname. With no
/api/v1/catalog* route registered on catalyst-api itself, the InstallPage
fetches from console.<sov> 404'd at chi NotFound — even though the same
URL on api.<sov> returned 401 (auth needed, not missing route).

Fix #5's HTTPRoute template explicitly noted this as the in-tier
follow-up. This PR adds the proxy:

  GET /api/v1/catalog                              -> List
  GET /api/v1/catalog/{name}                       -> Get
  GET /api/v1/catalog/{name}/versions/{version}    -> GetVersion

Handlers wrap the existing httpCatalogClient (already wired in main.go
via SetCatalogClient) so no new upstream config is introduced. Routes
are registered inside the auth.RequireSession group so the catalog
surface inherits the same session gate as the rest of /api/v1/*; the
caller's catalyst_session token is forwarded to catalyst-catalog so
its AnonymousReads / per-Org policy still applies.

Empty list returns {"items":[]} (never null) so the UI's
catalog.api.ts decoder + .map() in InstallPage don't trip.

Closes qa-loop iter-3 cluster: catalog-api-404 (TC-031/151/171).

Co-authored-by: hatiyildiz <hati.yildiz@openova.io>
This commit is contained in:
e3mrah 2026-05-09 17:54:24 +04:00 committed by GitHub
parent a308fcaa62
commit c9a46b4f37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 351 additions and 0 deletions

View File

@ -846,6 +846,24 @@ func main() {
rg.Get("/api/v1/sovereigns/{id}/applications/{name}/status", h.HandleApplicationStatus)
rg.Get("/api/v1/sovereigns/{id}/applications/{name}/stream", h.HandleApplicationStream)
// qa-loop iter-3 — catalog proxy. Slice-L originally exposed
// catalyst-catalog only via a Gateway HTTPRoute on the
// `api.<sovereign>` hostname; the Sovereign Console at
// `console.<sovereign>` proxies its `/api/*` calls through
// catalyst-api's ingress, so without these registrations the
// UI's `/api/v1/catalog*` fetches 404'd at chi's NotFound
// handler. See internal/handler/catalog_proxy.go for the
// architectural rationale (Fix #5's HTTPRoute template
// explicitly flagged this as the in-tier follow-up). Routes
// are session-gated like the rest of /api/v1/* — anonymous
// catalog browsing is not a UI surface and the catalog client
// forwards the caller's token so AnonymousReads policy on
// catalyst-catalog still applies if the upstream is configured
// for it.
rg.Get("/api/v1/catalog", h.HandleCatalogList)
rg.Get("/api/v1/catalog/{name}", h.HandleCatalogGet)
rg.Get("/api/v1/catalog/{name}/versions/{version}", h.HandleCatalogGetVersion)
// EPIC-6 (#1101) slice U-Fleet — multi-Sovereign fleet view.
// Read-only aggregator that backs the new live DashboardPage,
// per-Sovereign card detail rollup, and cross-Sovereign

View File

@ -0,0 +1,165 @@
// Package handler — catalog_proxy.go: qa-loop iter-3 fix for the
// /api/v1/catalog* surface on Sovereign Console.
//
// Background (qa-loop iter-1, Fix #5, PR #1186): catalyst-catalog runs
// behind catalyst-api in the Slice-L architecture, but the Slice-L
// chart originally exposed it via a Gateway HTTPRoute attached to the
// `api.<sovereign>` hostname only. The Sovereign Console is served at
// `console.<sovereign>` and proxies its `/api/*` calls through the
// catalyst-api ingress. With no `/api/v1/catalog*` route registered in
// catalyst-api itself, every UI catalog call from the console hostname
// 404'd at chi's NotFound handler — even though a direct curl to
// `https://api.<sovereign>/api/v1/catalog` returned the expected 401.
//
// Fix #5's HTTPRoute template explicitly noted this follow-up:
//
// "Operators who prefer to keep the catalog endpoint behind catalyst-api
// (rather than via a standalone HTTPRoute on the api hostname) can
// disable this template via .Values.services.catalog.httpRoute.enabled
// = false and add a proxy rule in catalyst-api instead — that path is
// left open as a follow-up so catalyst-api can do additional auth/audit
// wrapping."
//
// This file IS that proxy rule. Both surfaces (HTTPRoute + this proxy)
// can coexist: the HTTPRoute serves direct `api.<sovereign>` consumers
// (e.g. CLI tooling) while this proxy serves the in-tier
// `console.<sovereign>/api/v1/catalog*` UI fetches that go through
// catalyst-api's ingress.
//
// Endpoints exposed (all GET, session-gated):
//
// GET /api/v1/catalog — list (org from query)
// GET /api/v1/catalog/{name} — get latest
// GET /api/v1/catalog/{name}/versions/{version} — get version
//
// All three are thin proxies onto the existing `httpCatalogClient`
// (catalog_client.go) which is already wired in main.go via
// SetCatalogClient. Per docs/INVIOLABLE-PRINCIPLES.md #4 the upstream
// URL is configurable via CATALYST_CATALOG_URL — this proxy adds no
// new hardcoded URLs.
//
// Per docs/INVIOLABLE-PRINCIPLES.md #10 the session token is forwarded
// via the catalog client's attachAuth helper, never logged.
package handler
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// HandleCatalogList — proxies GET /api/v1/catalog?org=<slug> to
// catalyst-catalog. Returns the wire shape catalyst-catalog returns
// ({"items": [...]}) so the UI's catalog.api.ts decoder is unchanged.
//
// 502 when the catalog client is unwired (defensive: main.go always
// wires it, but a misconfigured deployment shouldn't 500).
// 502 when the upstream call fails — the upstream's status code is
// surfaced in the error body so the operator sees the real cause.
func (h *Handler) HandleCatalogList(w http.ResponseWriter, r *http.Request) {
if h.catalogClient == nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_unavailable",
"message": "catalog client not configured",
})
return
}
org := strings.TrimSpace(r.URL.Query().Get("org"))
tok := applicationSessionToken(r)
items, err := h.catalogClient.List(r.Context(), org, tok)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_upstream",
"message": err.Error(),
})
return
}
if items == nil {
// json marshalling: never emit `null` for a list endpoint.
items = []CatalogBlueprint{}
}
writeJSON(w, http.StatusOK, CatalogListResponse{Items: items})
}
// HandleCatalogGet — proxies GET /api/v1/catalog/{name} to
// catalyst-catalog (latest visible version). 404 when the upstream
// reports the blueprint doesn't exist (matches catalog_client's
// ErrBlueprintNotFound contract).
func (h *Handler) HandleCatalogGet(w http.ResponseWriter, r *http.Request) {
if h.catalogClient == nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_unavailable",
"message": "catalog client not configured",
})
return
}
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid_request",
"message": "name path parameter is required",
})
return
}
tok := applicationSessionToken(r)
bp, err := h.catalogClient.Get(r.Context(), name, tok)
if err != nil {
if errors.Is(err, ErrBlueprintNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "blueprint_not_found",
"message": "blueprint " + name + " not found in catalog",
})
return
}
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_upstream",
"message": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, bp)
}
// HandleCatalogGetVersion — proxies GET
// /api/v1/catalog/{name}/versions/{version} to catalyst-catalog. The
// upstream populates Raw on this endpoint so the install handler (and
// the InstallPage version-detail panel) can read configSchema without
// a second hop.
func (h *Handler) HandleCatalogGetVersion(w http.ResponseWriter, r *http.Request) {
if h.catalogClient == nil {
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_unavailable",
"message": "catalog client not configured",
})
return
}
name := strings.TrimSpace(chi.URLParam(r, "name"))
version := strings.TrimSpace(chi.URLParam(r, "version"))
if name == "" || version == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid_request",
"message": "name and version path parameters are required",
})
return
}
tok := applicationSessionToken(r)
bp, err := h.catalogClient.GetVersion(r.Context(), name, version, tok)
if err != nil {
if errors.Is(err, ErrBlueprintNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "blueprint_version_not_found",
"message": "blueprint " + name + "@" + version + " not found in catalog",
})
return
}
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "catalog_upstream",
"message": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, bp)
}

View File

@ -0,0 +1,168 @@
// catalog_proxy_test.go — qa-loop iter-3 coverage for the
// /api/v1/catalog* proxy on catalyst-api. Reuses the fakeCatalogClient
// + sampleWordpressBlueprint helpers defined in applications_test.go.
//
// Cases:
// - GET /api/v1/catalog → 200 with items array
// - GET /api/v1/catalog/{name} → 200 happy / 404 missing
// - GET /api/v1/catalog/{name}/versions/{version} → 200 happy / 404 missing
// - 502 when catalog client is unwired (defensive)
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func registerCatalogProxyRoutes(r chi.Router, h *Handler) {
r.Get("/api/v1/catalog", h.HandleCatalogList)
r.Get("/api/v1/catalog/{name}", h.HandleCatalogGet)
r.Get("/api/v1/catalog/{name}/versions/{version}", h.HandleCatalogGetVersion)
}
func TestHandleCatalogList_OK(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint()))
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body CatalogListResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v (body=%s)", err, rec.Body.String())
}
if len(body.Items) != 1 || body.Items[0].Name != "bp-wordpress" {
t.Fatalf("expected 1 item bp-wordpress, got %+v", body.Items)
}
}
func TestHandleCatalogList_EmptyNeverNull(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog())
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
// Wire shape MUST be {"items":[]} not {"items":null} so the UI's
// .map() / .find() in catalog.api.ts doesn't trip on null.
var raw map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode body: %v", err)
}
items, ok := raw["items"].([]interface{})
if !ok {
t.Fatalf("items not an array: %T %v", raw["items"], raw["items"])
}
if len(items) != 0 {
t.Fatalf("expected 0 items, got %d", len(items))
}
}
func TestHandleCatalogList_502WhenUnwired(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
// deliberately do NOT call SetCatalogClient
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleCatalogGet_OK(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint()))
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog/bp-wordpress", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var bp CatalogBlueprint
if err := json.Unmarshal(rec.Body.Bytes(), &bp); err != nil {
t.Fatalf("decode body: %v", err)
}
if bp.Name != "bp-wordpress" {
t.Fatalf("expected bp-wordpress, got %s", bp.Name)
}
}
func TestHandleCatalogGet_404Missing(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog()) // empty catalog
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog/bp-missing", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleCatalogGetVersion_OK(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint()))
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog/bp-wordpress/versions/1.2.3", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var bp CatalogBlueprint
if err := json.Unmarshal(rec.Body.Bytes(), &bp); err != nil {
t.Fatalf("decode body: %v", err)
}
if bp.Name != "bp-wordpress" || bp.Version != "1.2.3" {
t.Fatalf("expected bp-wordpress@1.2.3, got %s@%s", bp.Name, bp.Version)
}
// GetVersion populates Raw — confirm it's still on the wire.
if bp.Raw == nil {
t.Fatalf("expected Raw to be populated on getVersion response")
}
}
func TestHandleCatalogGetVersion_404Missing(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
h.SetCatalogClient(newFakeCatalog(sampleWordpressBlueprint()))
r := chi.NewRouter()
registerCatalogProxyRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/catalog/bp-wordpress/versions/9.9.9", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
}
}