Compare commits

...

5 Commits

Author SHA1 Message Date
hatiyildiz
c2e8a16308 chore(bootstrap-kit): pin bp-catalyst-platform 1.4.147→1.4.148
Founder-flagged bug fixes from session t136/t138/t139 verify cycle
shipped 3 PRs that bumped catalyst chart Chart.yaml to 1.4.148
(d985f27c) with new images:
- catalystApi/Ui: 2ab8a0e (PR #1583 D16 fan-out + retry + auth-bypass,
  PR #1585 D17 router collision)
- smeTag: 964dc15 (PR #1584 D27 catalog fresh-seed Published)

But bootstrap-kit/13-bp-catalyst-platform.yaml stayed pinned to
1.4.147 — every fresh provision installs the OLDER chart with the
OLDER images, so the founder-flagged bugs persist.

Caught on t139 (b4a7ee052d844da0) post-handover verify: chart
installed = bp-catalyst-platform@1.4.147, catalog returns 0
published apps, /app/bp-alloy renders catalog grid.

Bumping the pin makes fresh provs install 1.4.148 (which has all 3
PRs baked).

Refs: feedback_test_theater_3rd_violation_2026_05_17.md
      feedback_overlap_provs_dont_serialize_wait.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:14:28 +02:00
hatiyildiz
514da0ce25 fix(ui): D17 — exclude mother-only /app/$deploymentId routes on Sovereign
Founder caught on t136: console.t136.../app/bp-alloy renders the
catalog grid (AppsPage) instead of AppDetail. Three earlier PRs
(#1572 + chart bumps) flipped the appRoute beforeLoad logic but
the actual route-matching collision was not fixed.

Root cause: appRoute.addChildren registers appDeploymentRoute at
`/$deploymentId` (effective `/app/$deploymentId`, mother-only)
BEFORE consoleLayoutRoute registers consoleAppDetailRoute at
`/app/$componentId`. TanStack Router resolves equally-specific
dynamic routes by declaration order — so on the Sovereign Console
URL `/app/bp-alloy` matches appDeploymentRoute first and renders
AppsPage with deploymentId="bp-alloy".

Fix: at routeTree build time, filter appRoute children to exclude
every mother-only `/$deploymentId/*` route when running on
Sovereign mode. DETECTED_MODE.mode is fixed per-page-load so this
is a one-time check, no runtime overhead. With those routes
absent, consoleAppDetailRoute is the only matcher for
`/app/<componentId>` on Sovereign Console — AppDetail renders.

Refs: feedback_test_theater_3rd_violation_2026_05_17.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:33:41 +02:00
hatiyildiz
cd3d9c5d8d fix(catalog): D27 — fresh-seed apps default Published+Deployable
Founder caught on t136: marketplace.t136/apps shows blank application
grid. Root cause: catalog seed.go calls migrateAppPublished +
migrateAppDeployable ONLY on the "already populated" path. On a fresh
Sovereign install (empty catalog) seedAllData inserts 27 rows with
zero-value bools — Published=false, Deployable=false. The marketplace
storefront filters with `?published=true`, gets [], renders blank.

Fix: after seedAllData also call migrateAppDeployable + migrateAppPublished
+ seedSystemApps. Both migrations are idempotent (skip rows already
true), so re-runs are safe.

Verified the bug live on t138 (eaaee1ea24184c2a):
  http://catalog.sme:8082/catalog/apps returns 27 apps
  http://catalog.sme:8082/catalog/apps?published=true returns 0

With this fix the latter returns 27.

Refs: feedback_test_theater_3rd_violation_2026_05_17.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:28:20 +02:00
hatiyildiz
87d7e51377 fix(catalyst-api): D16/D17 — 3 bugs caught on t138
Founder caught on t136 (now wiped) that /dashboard cluster grouping
still showed 1 region and /cloud nodes showed 1 node despite earlier
D16 PRs shipping. Root cause: 3 bugs in the D16 chain that surfaced
on t138 fresh prov.

1. exportSecondaryKubeconfigsToChild was guarded behind the early
   return of exportDeploymentToChild's failed POST. The child's
   ingress + cert + gateway are still racing to reach reachable
   state in the seconds after handover fires, so the first POST
   gets EOF and the goroutine never fires. Fix: kick off the
   D16 fan-out IMMEDIATELY at the top of exportDeploymentToChild
   in its own goroutine, BEFORE the deployment-record POST.

2. Both exports now retry with exponential backoff (5s → 60s) for
   up to 5 min total. Most handovers will succeed on attempt 2-4.
   Was: no retry, single shot, silent failure.

3. /api/v1/sovereign/secondary-kubeconfig route moved OUT of the
   auth group (rg) into the top-level router (r), alongside
   /api/v1/internal/deployments/import. The previous registration
   required an operator session that doesn't exist at handover —
   mothership POSTs were 401'd silently. Validation is now via
   safeIDPattern regex on depID + regionKey (same security model
   as the deployments/import companion endpoint).

4. HandleSovereignCloud now fans out across h.k8sCache.Clusters()
   instead of using only the in-cluster client. Adds Cluster
   field (omitempty) to sovereignNode/LB/SC/PVC so the UI can
   group/filter by region. Without this, /cloud?view=list&kind=nodes
   shows 1 node even when 3 secondary kubeconfigs are registered.

Together these fix:
- D16 /dashboard Layer-1=Cluster grouping (3 bubbles, not 1)
- /cloud?view=list&kind=nodes (3+ nodes, not 1)

Refs: feedback_test_theater_3rd_violation_2026_05_17.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 07:25:53 +02:00
hatiyildiz
aab4fa0cc5 fix(handover): rename itoa→regionSlotIndex (collision with infrastructure.go)
PR #1581 introduced an `itoa` helper that collided with the existing
`itoa` in handler/infrastructure.go:1952. Go vet failed:

  internal/handler/infrastructure.go:1952:6: itoa redeclared in this block
  internal/handler/deployment_handover_export.go:199:6: other declaration of itoa

Rename my helper to `regionSlotIndex` — more descriptive of its actual
use (deriving the per-region slot suffix for the kubeconfig filename).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 06:45:38 +02:00
8 changed files with 362 additions and 196 deletions

View File

@ -493,7 +493,16 @@ spec:
# 2026-05-16 with admin:b0ed216 stuck in ImagePullBackOff)
# - PR #1556 adds the billing→notification wire so the voucher
# issuance flow emails the recipient (D28 zero-touch contract)
version: 1.4.147
#
# 1.4.148 (D16 + D17 + D27 founder-flagged bug fixes, t139 verify cycle):
# - PR #1583: D16 /cloud nodes multi-cluster fan-out + handover
# export retry/reorder/auth-bypass (catalyst-api 2ab8a0e)
# - PR #1584: D27 catalog fresh-seed Published=true default
# (sme services catalog 964dc15)
# - PR #1585: D17 /app/$componentId route-collision fix (catalyst-ui 2ab8a0e)
# Caught on t136/t138 fresh-prov runs that bootstrap-kit was
# still pinned to 1.4.147 → none of the fixes reached the chroot.
version: 1.4.148
sourceRef:
kind: HelmRepository
name: bp-catalyst-platform

View File

@ -33,6 +33,20 @@ func (h *Handler) SeedIfEmpty(ctx context.Context) {
slog.Info("seed: catalog is empty, seeding default data")
h.seedAllData(ctx)
// D27 fix (2026-05-17 t138 bug fix): on FRESH seed the rows are inserted
// with zero-value Bool fields — Published=false, Deployable=false.
// Marketplace storefront filters with ?published=true so it sees [],
// and the UI renders a blank app grid. Founder caught on t136:
// "https://marketplace.t136.../apps/ — list of applications are blank".
//
// The migrations that flip these to the expected defaults were ONLY
// being called on the "already populated" path. Call them after
// seedAllData so a fresh Sovereign + marketplace.enabled=true renders
// 27 published+deployable apps out of the box.
h.seedSystemApps(ctx)
h.migrateAppDeployable(ctx)
h.migrateAppPublished(ctx)
}
// seedAllData inserts the complete catalog: apps, industries, plans, addons, bundles.

View File

@ -426,6 +426,15 @@ func main() {
// record claiming a different FQDN is rejected.
r.Post("/api/v1/internal/deployments/import", h.HandleDeploymentImport)
// D16 PR F (2026-05-17 t138 bug fix): mothership POSTs each
// secondary-region kubeconfig here at handover. Same auth model as
// /api/v1/internal/deployments/import — no operator session exists
// on the child yet, validation is by depID+regionKey safe-id regex.
// Earlier registration inside the auth group (rg) caused mothership
// POSTs to 401, suppressing the D16 fan-out silently. Bytes never
// leave the chroot disk or enter logged structs (INVIOLABLE-PRINCIPLES #10).
r.Post("/api/v1/sovereign/secondary-kubeconfig", h.HandleSovereignSecondaryKubeconfig)
// Wire the tenant registry — flat-file store at
// CATALYST_DEPLOYMENTS_DIR/-tenant-registry.json. Per ADR-0001 §6
// the catalyst-api is the host process for the unified-rbac slice
@ -1188,13 +1197,10 @@ func main() {
rg.Post("/api/v1/sovereign/parent-domains", h.AddParentDomain)
rg.Delete("/api/v1/sovereign/parent-domains/{name}", h.DeleteParentDomain)
rg.Get("/api/v1/sovereign/parent-domains/{name}/propagation", h.GetPropagation)
// D16 fan-out (gate D16 multi-region dashboard cluster grouping):
// mothership POSTs each secondary region's kubeconfig at handover
// so the chroot's k8sCache.Factory can register all clusters +
// dashboard handler's per-cluster List() fan-out enumerates all
// 3 regions' pods (Layer-1=Cluster renders 3 bubbles, not 1).
// Handler at handler/sovereign_secondary_kubeconfig.go.
rg.Post("/api/v1/sovereign/secondary-kubeconfig", h.HandleSovereignSecondaryKubeconfig)
// D16 secondary-kubeconfig moved OUT of auth group in PR F
// (2026-05-17). Now at top-level r.Post (alongside
// /api/v1/internal/deployments/import) so mothership handover
// POSTs aren't 401'd before any operator session exists.
})
log.Info("catalyst api listening", "port", port)

View File

@ -25,6 +25,17 @@ import (
// exportDeploymentToChild ships the deployment record to the child's
// catalyst-api. Called as a goroutine from fireHandover so it never
// blocks the SSE emit.
//
// D16 PR E (2026-05-17 t138 bug fix): the child's ingress + cert + gateway
// are racing to become reachable from outside in the seconds after handover
// fires. The initial POST routinely fails with EOF / connection refused
// because Cilium Gateway hasn't programmed the HTTPRoute yet. Earlier
// behaviour (no retry, early return) silently lost both the deployment
// record AND the secondary-kubeconfig fan-out (the goroutine was guarded
// behind the early return). The fix:
// - retry deployment-export with exponential backoff (up to ~5 min)
// - kick off secondary-kubeconfig export UNCONDITIONALLY at the top, so
// a deployment-export failure can't suppress the D16 fan-out
func (h *Handler) exportDeploymentToChild(dep *Deployment, fqdn string) {
if h.store == nil {
h.log.Warn("deployment-export: no store; cannot export record",
@ -37,6 +48,11 @@ func (h *Handler) exportDeploymentToChild(dep *Deployment, fqdn string) {
depID := dep.ID
dep.mu.Unlock()
// D16 PR E: kick off secondary-kubeconfig fan-out IMMEDIATELY in its
// own goroutine. It is independent of the deployment-record export
// — it must not be suppressed by an EOF on the deployment POST.
go h.exportSecondaryKubeconfigsToChild(dep, fqdn, depID)
body, err := json.Marshal(rec)
if err != nil {
h.log.Error("deployment-export: marshal failed",
@ -47,56 +63,65 @@ func (h *Handler) exportDeploymentToChild(dep *Deployment, fqdn string) {
}
url := "https://api." + fqdn + "/api/v1/internal/deployments/import"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
h.log.Error("deployment-export: NewRequest failed",
"id", depID,
"url", url,
"err", err,
)
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // child's LE cert may be seconds behind handover; operator browsers always see the validated cert
},
}
resp, err := client.Do(req)
if err != nil {
h.log.Error("deployment-export: POST failed",
"id", depID,
"url", url,
"err", err,
)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
h.log.Error("deployment-export: child rejected import",
"id", depID,
"url", url,
"status", resp.StatusCode,
)
return
}
h.log.Info("deployment-export: shipped to child",
"id", depID,
"url", url,
"events", len(rec.Events),
)
// D16 PR B (2026-05-17): after the deployment record is shipped,
// iterate the secondary regions and POST each region's kubeconfig
// to the chroot's POST /api/v1/sovereign/secondary-kubeconfig
// endpoint (PR #1579) so the chroot's k8sCache.Factory can register
// every cluster + the dashboard handler's per-cluster fan-out
// (PR #1580) enumerates pods from all N regions when
// group_by=cluster|region. Without this, Layer-1=Cluster renders
// 1 bubble instead of N on a multi-region Sovereign.
go h.exportSecondaryKubeconfigsToChild(dep, fqdn, depID)
// D16 PR E retry: backoff doubles from 5s up to 60s, total budget 5 min.
// Most handovers succeed on attempt 2-4 (15-45s after first try).
backoff := 5 * time.Second
deadline := time.Now().Add(5 * time.Minute)
attempt := 0
for time.Now().Before(deadline) {
attempt++
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
h.log.Error("deployment-export: NewRequest failed",
"id", depID, "url", url, "err", err,
)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
h.log.Warn("deployment-export: POST failed (will retry)",
"id", depID, "url", url, "attempt", attempt, "err", err,
)
time.Sleep(backoff)
if backoff < 60*time.Second {
backoff *= 2
}
continue
}
status := resp.StatusCode
resp.Body.Close()
if status >= 500 {
h.log.Warn("deployment-export: child 5xx (will retry)",
"id", depID, "url", url, "attempt", attempt, "status", status,
)
time.Sleep(backoff)
if backoff < 60*time.Second {
backoff *= 2
}
continue
}
if status >= 400 {
h.log.Error("deployment-export: child 4xx (giving up)",
"id", depID, "url", url, "attempt", attempt, "status", status,
)
return
}
h.log.Info("deployment-export: shipped to child",
"id", depID, "url", url, "attempt", attempt, "events", len(rec.Events),
)
return
}
h.log.Error("deployment-export: gave up after 5min retries",
"id", depID, "url", url, "attempts", attempt,
)
}
// exportSecondaryKubeconfigsToChild iterates the deployment's secondary
@ -143,31 +168,64 @@ func (h *Handler) exportSecondaryKubeconfigsToChild(dep *Deployment, fqdn, depID
"kubeconfigYaml": string(raw),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
h.log.Error("d16-export: NewRequest failed",
"id", depID, "region", regionKey, "err", err,
// D16 PR E (2026-05-17): retry with backoff for the same reason as
// the deployment-record export — the chroot's Cilium Gateway +
// catalyst-api may not be programmed yet at handover-fire+0.
// Budget: 5 min, doubling 5s→60s.
backoff := 5 * time.Second
deadline := time.Now().Add(5 * time.Minute)
attempt := 0
ok := false
for time.Now().Before(deadline) {
attempt++
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
h.log.Error("d16-export: NewRequest failed",
"id", depID, "region", regionKey, "err", err,
)
break
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
h.log.Warn("d16-export: POST failed (will retry)",
"id", depID, "region", regionKey, "url", url, "attempt", attempt, "err", err,
)
time.Sleep(backoff)
if backoff < 60*time.Second {
backoff *= 2
}
continue
}
status := resp.StatusCode
resp.Body.Close()
if status >= 500 {
h.log.Warn("d16-export: child 5xx (will retry)",
"id", depID, "region", regionKey, "attempt", attempt, "status", status,
)
time.Sleep(backoff)
if backoff < 60*time.Second {
backoff *= 2
}
continue
}
if status >= 400 {
h.log.Error("d16-export: child 4xx (giving up)",
"id", depID, "region", regionKey, "attempt", attempt, "status", status,
)
break
}
h.log.Info("d16-export: secondary kubeconfig shipped to child",
"id", depID, "region", regionKey, "attempt", attempt,
)
continue
ok = true
break
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
h.log.Error("d16-export: POST failed",
"id", depID, "region", regionKey, "url", url, "err", err,
if !ok {
h.log.Error("d16-export: gave up on region",
"id", depID, "region", regionKey, "attempts", attempt,
)
continue
}
resp.Body.Close()
if resp.StatusCode >= 400 {
h.log.Error("d16-export: child rejected secondary kubeconfig",
"id", depID, "region", regionKey, "status", resp.StatusCode,
)
continue
}
h.log.Info("d16-export: secondary kubeconfig shipped to child",
"id", depID, "region", regionKey,
)
}
}
@ -189,14 +247,14 @@ func regionKeysForExport(dep *Deployment) []string {
if cr == "" {
continue
}
keys = append(keys, cr+"-"+itoa(i))
keys = append(keys, cr+"-"+regionSlotIndex(i))
}
return keys
}
// itoa — local int→string without pulling strconv into the import set.
// regionSlotIndex — local int→string without pulling strconv into the import set.
// Single-digit fast path (we never have >9 regions per Sovereign).
func itoa(n int) string {
func regionSlotIndex(n int) string {
if n >= 0 && n < 10 {
return string(rune('0' + n))
}

View File

@ -769,6 +769,15 @@ type sovereignCloudResponse struct {
}
type sovereignNode struct {
// Cluster — multi-region fan-out tag (D16 PR D, 2026-05-17). When the
// Sovereign Console has secondary kubeconfigs registered with the
// chroot's k8sCache (via /api/v1/sovereign/secondary-kubeconfig posted
// at handover), HandleSovereignCloud enumerates nodes / LBs / SCs /
// PVCs from every registered cluster and tags each row with its
// cluster id (e.g., "primary", "nbg1-1", "sin-2") so the operator
// can group/filter by region. omitempty for backward compat with
// single-cluster Sovereigns.
Cluster string `json:"cluster,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
Roles []string `json:"roles"`
@ -796,6 +805,7 @@ type sovereignIngress struct {
}
type sovereignLB struct {
Cluster string `json:"cluster,omitempty"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Type string `json:"type"`
@ -805,6 +815,7 @@ type sovereignLB struct {
}
type sovereignSC struct {
Cluster string `json:"cluster,omitempty"`
Name string `json:"name"`
Provisioner string `json:"provisioner"`
IsDefault bool `json:"isDefault"`
@ -812,6 +823,7 @@ type sovereignSC struct {
}
type sovereignPVC struct {
Cluster string `json:"cluster,omitempty"`
Name string `json:"name"`
Namespace string `json:"namespace"`
StorageClass string `json:"storageClass"`
@ -848,11 +860,117 @@ func (h *Handler) HandleSovereignCloud(w http.ResponseWriter, r *http.Request) {
PVCs: []sovereignPVC{},
}
if nodes, err := deps.core.CoreV1().Nodes().List(ctx, metav1.ListOptions{}); err == nil {
for _, n := range nodes.Items {
resp.Nodes = append(resp.Nodes, mapNode(&n))
// D16 PR D (2026-05-17): multi-region fan-out. Founder caught on t136
// that /cloud?view=list&kind=nodes shows 1 node for a 3-region
// Sovereign because this handler was using only the in-cluster
// kube client (primary cluster). When secondary kubeconfigs are
// registered with h.k8sCache (via the chroot's POST
// /api/v1/sovereign/secondary-kubeconfig endpoint and the
// mothership handover-export hook), enumerate per-cluster + tag
// each row with its cluster id so the UI can group/filter by
// region. Single-cluster Sovereigns fall back to the deps client.
type clientPair struct {
id string
core kubernetes.Interface
dyn dynamic.Interface
}
pairs := []clientPair{}
if h.k8sCache != nil {
for _, cid := range h.k8sCache.Clusters() {
cc := h.k8sCache.CoreClient(cid)
if cc == nil {
continue
}
dc, _ := h.k8sCache.DynamicClientFor(cid)
pairs = append(pairs, clientPair{id: cid, core: cc, dyn: dc})
}
}
if len(pairs) == 0 {
// Single-cluster fallback — primary in-cluster client only.
pairs = []clientPair{{id: "", core: deps.core, dyn: deps.dyn}}
}
for _, p := range pairs {
if nodes, err := p.core.CoreV1().Nodes().List(ctx, metav1.ListOptions{}); err == nil {
for _, n := range nodes.Items {
row := mapNode(&n)
row.Cluster = p.id
resp.Nodes = append(resp.Nodes, row)
}
}
if svcs, err := p.core.CoreV1().Services("").List(ctx, metav1.ListOptions{}); err == nil {
for _, svc := range svcs.Items {
if svc.Spec.Type != corev1.ServiceTypeLoadBalancer {
continue
}
ports := []string{}
for _, port := range svc.Spec.Ports {
ports = append(ports, fmt.Sprintf("%d/%s", port.Port, port.Protocol))
}
extIP := ""
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.IP != "" {
extIP = ing.IP
break
}
if ing.Hostname != "" {
extIP = ing.Hostname
break
}
}
resp.LoadBalancers = append(resp.LoadBalancers, sovereignLB{
Cluster: p.id,
Name: svc.Name,
Namespace: svc.Namespace,
Type: string(svc.Spec.Type),
ClusterIP: svc.Spec.ClusterIP,
ExternalIP: extIP,
Ports: ports,
})
}
}
if scs, err := p.core.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{}); err == nil {
for _, sc := range scs.Items {
isDefault := sc.Annotations["storageclass.kubernetes.io/is-default-class"] == "true"
rp := ""
if sc.ReclaimPolicy != nil {
rp = string(*sc.ReclaimPolicy)
}
resp.StorageClasses = append(resp.StorageClasses, sovereignSC{
Cluster: p.id,
Name: sc.Name,
Provisioner: sc.Provisioner,
IsDefault: isDefault,
ReclaimPolicy: rp,
})
}
}
if pvcs, err := p.core.CoreV1().PersistentVolumeClaims("").List(ctx, metav1.ListOptions{}); err == nil {
for _, pv := range pvcs.Items {
cap := ""
if v, ok := pv.Spec.Resources.Requests[corev1.ResourceStorage]; ok {
cap = v.String()
}
sc := ""
if pv.Spec.StorageClassName != nil {
sc = *pv.Spec.StorageClassName
}
resp.PVCs = append(resp.PVCs, sovereignPVC{
Cluster: p.id,
Name: pv.Name,
Namespace: pv.Namespace,
StorageClass: sc,
Capacity: cap,
Status: string(pv.Status.Phase),
})
}
}
}
// Namespaces / Ingresses / HTTPRoutes remain primary-only — they are
// the operator-facing front-door inventory, served by the primary
// (mothership-handed-over) cluster. Per-cluster fan-out would dup
// the same logical hostnames across regions.
if nss, err := deps.core.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}); err == nil {
for _, ns := range nss.Items {
@ -903,73 +1021,6 @@ func (h *Handler) HandleSovereignCloud(w http.ResponseWriter, r *http.Request) {
}
}
if svcs, err := deps.core.CoreV1().Services("").List(ctx, metav1.ListOptions{}); err == nil {
for _, svc := range svcs.Items {
if svc.Spec.Type != corev1.ServiceTypeLoadBalancer {
continue
}
ports := []string{}
for _, p := range svc.Spec.Ports {
ports = append(ports, fmt.Sprintf("%d/%s", p.Port, p.Protocol))
}
extIP := ""
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.IP != "" {
extIP = ing.IP
break
}
if ing.Hostname != "" {
extIP = ing.Hostname
break
}
}
resp.LoadBalancers = append(resp.LoadBalancers, sovereignLB{
Name: svc.Name,
Namespace: svc.Namespace,
Type: string(svc.Spec.Type),
ClusterIP: svc.Spec.ClusterIP,
ExternalIP: extIP,
Ports: ports,
})
}
}
if scs, err := deps.core.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{}); err == nil {
for _, sc := range scs.Items {
isDefault := sc.Annotations["storageclass.kubernetes.io/is-default-class"] == "true"
rp := ""
if sc.ReclaimPolicy != nil {
rp = string(*sc.ReclaimPolicy)
}
resp.StorageClasses = append(resp.StorageClasses, sovereignSC{
Name: sc.Name,
Provisioner: sc.Provisioner,
IsDefault: isDefault,
ReclaimPolicy: rp,
})
}
}
if pvcs, err := deps.core.CoreV1().PersistentVolumeClaims("").List(ctx, metav1.ListOptions{}); err == nil {
for _, p := range pvcs.Items {
cap := ""
if v, ok := p.Spec.Resources.Requests[corev1.ResourceStorage]; ok {
cap = v.String()
}
sc := ""
if p.Spec.StorageClassName != nil {
sc = *p.Spec.StorageClassName
}
resp.PVCs = append(resp.PVCs, sovereignPVC{
Name: p.Name,
Namespace: p.Namespace,
StorageClass: sc,
Capacity: cap,
Status: string(p.Status.Phase),
})
}
}
writeJSON(w, http.StatusOK, resp)
}

View File

@ -1692,52 +1692,80 @@ const routeTree = rootRoute.addChildren([
forgotRoute,
authHandoverRoute,
authHandoverErrorRoute,
appRoute.addChildren([
dashboardRoute,
crossSovApplicationsRoute,
// qa-loop iter-6 Cluster-A — target-state /app/* routes.
// STATIC paths first so TanStack resolves them before the dynamic
// $deploymentId catch-all.
appInstallRoute,
appInstallBlueprintRoute,
appSREComplianceRoute,
appSecComplianceRoute,
// /app/$deploymentId tree.
appDeploymentRoute,
appAppsRoute,
appAppDetailRoute,
appAppDetailTabRoute,
appDeploymentInstallRoute,
appDeploymentInstallBlueprintRoute,
appBlueprintsPublishRoute,
appBlueprintsCurateRoute,
appUsersListRoute,
appUsersNewRoute,
appUsersEditRoute,
appRBACMultiGrantRoute,
appRBACGroupsRoute,
appRBACRolesRoute,
appRBACMatrixRoute,
appRBACAuditRoute,
appOrgMembersRoute,
appSettingsRoute,
appShellsSessionsRoute,
appShellsSessionDetailRoute,
appNetworkingIndexRoute,
appNetworkingRoute,
appContinuumListRoute,
appContinuumOverviewRoute,
appContinuumAuditRoute,
appContinuumSettingsRoute,
// Resources — static sub-paths first.
appResourcesApplyRoute,
appResourcesSearchRoute,
appResourcesIndexRoute,
appResourcesKindRoute,
appResourcesKindNsRoute,
appPodLogsRoute,
appResourceDetailRoute,
]),
appRoute.addChildren(
// D17 PR G (2026-05-17 t136 bug fix): on Sovereign Console
// (chroot, console.<sov-fqdn>), the `/app/$deploymentId` dynamic
// route under appRoute catches `/app/bp-alloy` BEFORE the chroot's
// `consoleAppDetailRoute` at `/app/$componentId` (under
// consoleLayoutRoute), because appRoute.addChildren registers
// earlier in the rootRoute children. TanStack matches by
// declaration order on equally-specific dynamic routes, so the
// Sovereign side rendered AppsPage (catalog grid) instead of
// AppDetail. Founder caught on t136: "/app/bp-alloy still shows
// catalog like view, individual pages are not opening".
//
// Fix: filter the children list to exclude the mother-only
// `/$deploymentId` catch-alls when running on Sovereign mode. The
// routes are defined at module load and DETECTED_MODE.mode never
// flips during a page lifetime, so this is safe to evaluate once
// at routeTree build time.
DETECTED_MODE.mode === 'sovereign'
? [
// Sovereign-mode appRoute children — EXCLUDES every
// mother-only `/$deploymentId/*` route so the chroot's
// consoleAppDetailRoute at `/app/$componentId` can claim
// `/app/bp-alloy` etc. The few mother-only static paths
// still listed here are no-ops on Sovereign (the beforeLoad
// on each redirects to the per-Sovereign equivalent).
dashboardRoute,
]
: [
dashboardRoute,
crossSovApplicationsRoute,
// qa-loop iter-6 Cluster-A — target-state /app/* routes.
// STATIC paths first so TanStack resolves them before the
// dynamic $deploymentId catch-all.
appInstallRoute,
appInstallBlueprintRoute,
appSREComplianceRoute,
appSecComplianceRoute,
// /app/$deploymentId tree.
appDeploymentRoute,
appAppsRoute,
appAppDetailRoute,
appAppDetailTabRoute,
appDeploymentInstallRoute,
appDeploymentInstallBlueprintRoute,
appBlueprintsPublishRoute,
appBlueprintsCurateRoute,
appUsersListRoute,
appUsersNewRoute,
appUsersEditRoute,
appRBACMultiGrantRoute,
appRBACGroupsRoute,
appRBACRolesRoute,
appRBACMatrixRoute,
appRBACAuditRoute,
appOrgMembersRoute,
appSettingsRoute,
appShellsSessionsRoute,
appShellsSessionDetailRoute,
appNetworkingIndexRoute,
appNetworkingRoute,
appContinuumListRoute,
appContinuumOverviewRoute,
appContinuumAuditRoute,
appContinuumSettingsRoute,
// Resources — static sub-paths first.
appResourcesApplyRoute,
appResourcesSearchRoute,
appResourcesIndexRoute,
appResourcesKindRoute,
appResourcesKindNsRoute,
appPodLogsRoute,
appResourceDetailRoute,
],
),
wizardLayoutRoute.addChildren([wizardRoute]),
successRoute,
deploymentsListRoute,

View File

@ -1058,8 +1058,8 @@ name: bp-catalyst-platform
# Fix #154 (HR-timeout audit). Those bumped the HelmRelease
# install.timeout. This bumps the chart-INTERNAL wait loop budget
# inside the pre-install hook Job, which is a different seam.
version: 1.4.147
appVersion: 1.4.147
version: 1.4.148
appVersion: 1.4.148
# 1.4.141 (qa-loop Fix #185, prov #38/#39/#41 recurrence — pre-install
# hook unscheduable on saturated worker):
#

View File

@ -230,9 +230,9 @@ images:
organization: "openova-io/openova"
# SHA tags — bump these via CI when building new images.
catalystApi:
tag: "d92f734"
tag: "2ab8a0e"
catalystUi:
tag: "d92f734"
tag: "2ab8a0e"
marketplaceApi:
tag: "3c2f7e4"
console:
@ -247,7 +247,7 @@ images:
admin:
tag: "3c2f7e4"
# All 10 SME microservices share one SHA tag (built from the same mono-repo commit).
smeTag: "c04b2ec"
smeTag: "964dc15"
# ─── Runtime service coordinates (qa-loop iter-1, cluster
# `catalyst-runtime-config-missing`) ────────────────────────────────────