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:
parent
a308fcaa62
commit
c9a46b4f37
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user