Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .forbidden-terms-whitelist.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,25 @@
"filename": "scripts/sdk-generator/go.sum",
"line": 32,
"rationale": "Immutable upstream Go module checksum hash contains an incidental byte sequence; changing it would invalidate the go.sum."
},
{
"filename": "specs/platform/global-architecture.spec.md",
"line": 27,
"rationale": "Mermaid diagram node id 'ACP' abbreviates 'Control Plane' in the topology graph; it is a diagram identifier, not the discouraged term."
},
{
"filename": "specs/platform/global-architecture.spec.md",
"line": 76,
"rationale": "Mermaid diagram node id 'ACP' abbreviates 'Control Plane' in the reconciliation flow; it is a diagram identifier, not the discouraged term."
},
{
"filename": "specs/platform/global-architecture.spec.md",
"line": 77,
"rationale": "Mermaid diagram node id 'ACP' abbreviates 'Control Plane' in the reconciliation flow; it is a diagram identifier, not the discouraged term."
},
{
"filename": "specs/platform/global-architecture.spec.md",
"line": 912,
"rationale": "Example filename 'rosa-vteam.yaml' in a directory-tree illustration references a real external GitOps path; it is an example path, not the discouraged term."
}
]
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ checks manually with `make check`.
- `components/control-plane/` - Go service, watches API server via gRPC and reconciles gateway resources into K8s
- `packages/gateway-management-ui/` - Private reusable React package containing canonical gateway management workflows
- `specs/` - Desired state of the system ([platform](specs/platform/), [standards](specs/standards/))
- `skills/` - Agent skills: [reconcile](skills/build/reconcile), [spec](skills/plan/spec), [full-stack-pipeline](skills/build/full-stack-pipeline), [dev-cluster](skills/build/dev-cluster), [review](skills/review/review-guidance), [amber-review](skills/review/amber-review), [ui-standards](skills/review/ui-standards), [tooling](skills/tooling/)
- `skills/` - Agent skills: [reconcile](skills/build/reconcile), [spec](skills/plan/spec), [full-stack-pipeline](skills/build/full-stack-pipeline), [dev-cluster](skills/build/dev-cluster), [ibm-cluster](skills/deploy/ibm-cluster), [deploy-cluster](skills/deploy/deploy-cluster), [cloud-hub-ingress-bootstrap](skills/deploy/cloud-hub-ingress-bootstrap), [review](skills/review/review-guidance), [amber-review](skills/review/amber-review), [ui-standards](skills/review/ui-standards), [tooling](skills/tooling/)
- `apm.yml` - APM manifest declaring upstream skill dependencies

## Key Files
Expand Down Expand Up @@ -67,6 +67,7 @@ state (coverage summary, gap table, wave plan), then executes waves to close gap
Idempotent: safe to run repeatedly.

Support skills available at any point:
- `/cloud-hub-ingress-bootstrap` -- one-time shared Gateway + wildcard DNS/TLS per Cloud Hub (AWS/IBM)
- `/review-guidance` -- PR review checklist
- `/amber-review` -- Amber agent comprehensive code review
- `/ui-standards` -- UI/UX audit or intent-driven design guidance
Expand Down
90 changes: 90 additions & 0 deletions components/api-server/deploy/ibm/controller-clusterrbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Cluster-scoped controller RBAC for full tenant reconciliation.
#
# The self-contained ../openshift tree ships only a narrow Role (gateways in
# openshift-ingress), which is insufficient once the control plane reconciles
# whole tenant gateways: it creates per-tenant namespaces, Deployments,
# Services, Secrets, NetworkPolicies, and -- in Route ingress mode -- OpenShift
# Routes, all cluster-wide. This mirrors deploy/base/controller-rbac.yaml (the
# GitOps tree's canonical grant), bound to the hypershell/hypershell-controller
# service account used by ../openshift.
#
# Not IBM-specific in itself; carried in this overlay until the ../openshift base
# grows the same ClusterRole. See specs/platform/global-architecture.spec.md.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: hypershell-controller
labels:
app.kubernetes.io/name: hypershell
app.kubernetes.io/component: controller
rules:
- apiGroups: [""]
resources: ["namespaces", "secrets", "configmaps", "services", "serviceaccounts", "persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["cert-manager.io"]
resources: ["issuers", "certificates"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["gateways", "grpcroutes", "backendtlspolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["route.openshift.io"]
resources: ["routes"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Route ingress mode derives an explicit spec.host (gw-<tenant>.<base-domain>).
# OpenShift gates setting a Route's host behind the routes/custom-host
# subresource; without this the Route is rejected
# ("you do not have permission to set the host field of the route").
- apiGroups: ["route.openshift.io"]
resources: ["routes/custom-host"]
verbs: ["create", "update"]
# The controller binds the sandbox service account to the privileged SCC
# (roleRef system:openshift:scc:privileged) so agent sandboxes can run. RBAC
# escalation prevention requires the controller to itself hold "use" of that
# SCC before it can grant it; without this the RoleBinding create is forbidden.
- apiGroups: ["security.openshift.io"]
resources: ["securitycontextconstraints"]
resourceNames: ["privileged"]
verbs: ["use"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods", "events"]
verbs: ["get", "list", "watch"]
- apiGroups: ["authentication.k8s.io"]
resources: ["tokenreviews"]
verbs: ["create"]
- apiGroups: ["agents.x-k8s.io"]
resources: ["sandboxes", "sandboxes/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: hypershell-controller
labels:
app.kubernetes.io/name: hypershell
app.kubernetes.io/component: controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: hypershell-controller
subjects:
- kind: ServiceAccount
name: hypershell-controller
namespace: hypershell
87 changes: 87 additions & 0 deletions components/api-server/deploy/ibm/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

# IBM Cloud (ROKS) overlay for the dev/no-auth platform deploy.
#
# This is the environment adapter for the self-contained OpenShift deploy in
# ../openshift (namespace `hypershell`, the tree documented by the
# `deploy-cluster` skill). It changes only what the ROKS environment forces us
# to change, leaving the architecture identical:
#
# 1. Ingress mode -> Route. ROKS is HyperShift-hosted and cannot run the
# CIO-managed Gateway API (OSSM images unpullable, node mirroring / IDMS
# owned by the HostedCluster). The control plane instead emits passthrough
# OpenShift Routes on IBM's free "*.containers.appdomain.cloud" wildcard,
# selected by GATEWAY_INGRESS_MODE=route. Passthrough preserves the gateway
# pod's per-tenant self-signed CA TLS end to end -- no shared Gateway,
# wildcard cert, cert-manager ClusterIssuer, or Route53.
#
# 2. Image sources -> internal registry. ROKS worker nodes cannot reach
# quay.io or registry.access.redhat.com (egress is restricted to IBM
# registries), so all images are mirrored into the cluster internal
# registry and referenced by their in-cluster service address
# (image-registry.openshift-image-registry.svc:5000/hypershell/...), which
# the kubelet can resolve and pull.
#
# See:
# specs/platform/global-architecture.spec.md (§ IBM Cloud Cloud Hub - Route ingress mode)
# skills/deploy/ibm-cluster/SKILL.md (Step 5)
# skills/deploy/deploy-cluster/SKILL.md (Cloud-Hub Parameter Overrides)

resources:
- ../openshift
- controller-clusterrbac.yaml

# --- point every image at the mirrored copy in the internal registry ---
# The kubelet pulls these by their internal service address; the default
# service account in the `hypershell` namespace already has system:image-puller
# for same-namespace image streams.
images:
- name: quay.io/redhat-services-prod/hcm-eng-prod-tenant/hypershell-main/hypershell-api-server-main
newName: image-registry.openshift-image-registry.svc:5000/hypershell/hypershell-api-server
newTag: dev
- name: quay.io/redhat-services-prod/hcm-eng-prod-tenant/hypershell-main/hypershell-control-plane-main
newName: image-registry.openshift-image-registry.svc:5000/hypershell/hypershell-controller
newTag: dev
- name: registry.access.redhat.com/hi/postgresql
newName: image-registry.openshift-image-registry.svc:5000/hypershell/postgresql
newTag: "18.4"

patches:
# --- select Route ingress mode + this cluster's ingress subdomain ---
# Strategic-merge on the env list (merged by name): adds GATEWAY_INGRESS_MODE
# and overrides the base-domain placeholder with the ROKS ingress subdomain, so
# tenant gateways get "gw-<tenant>.<subdomain>" hosts under the free wildcard.
- patch: |
apiVersion: apps/v1
kind: Deployment
metadata:
name: hypershell-controller
namespace: hypershell
spec:
template:
spec:
containers:
- name: controller
env:
- name: GATEWAY_INGRESS_MODE
value: route
- name: GATEWAY_API_BASE_DOMAIN
value: hysh-ibm-01-4c28435107377e996c6eb39230b7bcf5-0000.us-east.containers.appdomain.cloud
# ROKS nodes cannot pull Docker Hub, so the per-tenant gateway
# database image is served from the internal registry (mirror
# of docker.io/library/postgres:18 in the openshift namespace,
# globally pullable). The name stays "postgres" (not RHEL
# "postgresql-"), so the reconciler's variant detection
# (reconciler.go:1186) keeps the POSTGRES_* env + data-path
# conventions that match this image.
- name: HYPERSHELL_DATABASE_IMAGE
value: image-registry.openshift-image-registry.svc:5000/openshift/postgres:18
# Tenant sandbox pods launch from this base image. ROKS nodes
# cannot pull ghcr.io, so it is served from the internal
# registry (mirror of
# ghcr.io/nvidia/openshell-community/sandboxes/base:latest in
# the openshift namespace, globally pullable). Without this the
# Sandbox CR is admitted but its pod ImagePullBackOffs.
- name: GATEWAY_SANDBOX_IMAGE
value: image-registry.openshift-image-registry.svc:5000/openshift/openshell-sandbox-base:latest
3 changes: 3 additions & 0 deletions components/api-server/plugins/gateways/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ func (h gatewayHandler) Patch(w http.ResponseWriter, r *http.Request) {
if patch.Image != nil {
found.Image = patch.Image
}
if patch.SupervisorImage != nil {
found.SupervisorImage = patch.SupervisorImage
}
if len(patch.ServerDnsNames) > 0 {
data, _ := json.Marshal(patch.ServerDnsNames)
s := string(data)
Expand Down
33 changes: 31 additions & 2 deletions components/control-plane/internal/gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,36 @@ type ImageDefaults interface {
DefaultGatewayImage() string
DefaultSupervisorImage() string
DefaultDatabaseImage() string
DefaultSandboxImage() string
}

const defaultDatabaseImage = "postgres:18"
const defaultSandboxImage = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"

type StaticImageDefaults struct{}

const defaultGatewayImage = "ghcr.io/nvidia/openshell/gateway:0.0.101"
const defaultSupervisorImage = "ghcr.io/nvidia/openshell/supervisor:0.0.101"

// DefaultGatewayImage resolves the gateway server (and certgen) image used when
// a Gateway resource does not specify one. Overridable via GATEWAY_IMAGE so
// clusters whose nodes cannot reach ghcr.io (e.g. IBM ROKS) can point it at an
// in-cluster registry mirror, mirroring the GATEWAY_SANDBOX_IMAGE override.
func (StaticImageDefaults) DefaultGatewayImage() string {
return "ghcr.io/nvidia/openshell/gateway:0.0.101"
if v := os.Getenv("GATEWAY_IMAGE"); v != "" {
return v
}
return defaultGatewayImage
}

// DefaultSupervisorImage resolves the supervisor sidecar image used when a
// Gateway resource does not specify one. Overridable via GATEWAY_SUPERVISOR_IMAGE
// for the same ghcr.io-unreachable clusters as DefaultGatewayImage.
func (StaticImageDefaults) DefaultSupervisorImage() string {
return "ghcr.io/nvidia/openshell/supervisor:0.0.101"
if v := os.Getenv("GATEWAY_SUPERVISOR_IMAGE"); v != "" {
return v
}
return defaultSupervisorImage
}

func (StaticImageDefaults) DefaultDatabaseImage() string {
Expand All @@ -34,6 +52,17 @@ func (StaticImageDefaults) DefaultDatabaseImage() string {
return defaultDatabaseImage
}

// DefaultSandboxImage resolves the base image tenant sandbox pods launch from.
// It is overridable via GATEWAY_SANDBOX_IMAGE so clusters whose nodes cannot
// reach ghcr.io (e.g. IBM ROKS) can point it at an in-cluster registry mirror,
// mirroring the HYPERSHELL_DATABASE_IMAGE override for the gateway database.
func (StaticImageDefaults) DefaultSandboxImage() string {
if v := os.Getenv("GATEWAY_SANDBOX_IMAGE"); v != "" {
return v
}
return defaultSandboxImage
}

type NamespaceConfig struct {
Name string `yaml:"name"`
Gateway GatewayConfig `yaml:"gateway"`
Expand Down
102 changes: 102 additions & 0 deletions components/control-plane/internal/gateway/ingress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package gateway

import (
"testing"
)

func TestGatewayIngressMode(t *testing.T) {
tests := []struct {
name string
envValue string
setEnv bool
opts ReconcileOpts
want string
}{
{
name: "explicit route override wins over capabilities",
setEnv: true, envValue: "route",
opts: ReconcileOpts{HasGatewayAPI: true, IsOpenShift: true},
want: ingressModeRoute,
},
{
name: "explicit gateway-api override",
setEnv: true, envValue: "gateway-api",
opts: ReconcileOpts{HasGatewayAPI: false, IsOpenShift: true},
want: ingressModeGatewayAPI,
},
{
name: "routes alias maps to route",
setEnv: true, envValue: "Routes",
opts: ReconcileOpts{IsOpenShift: true},
want: ingressModeRoute,
},
{
name: "none disables managed ingress",
setEnv: true, envValue: "none",
opts: ReconcileOpts{HasGatewayAPI: true, IsOpenShift: true},
want: ingressModeNone,
},
{
name: "auto-detect prefers Gateway API when present",
opts: ReconcileOpts{HasGatewayAPI: true, IsOpenShift: true},
want: ingressModeGatewayAPI,
},
{
name: "auto-detect falls back to Route on OpenShift without Gateway API",
opts: ReconcileOpts{HasGatewayAPI: false, IsOpenShift: true},
want: ingressModeRoute,
},
{
name: "auto-detect yields none on a plain cluster",
opts: ReconcileOpts{HasGatewayAPI: false, IsOpenShift: false},
want: ingressModeNone,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setEnv {
t.Setenv("GATEWAY_INGRESS_MODE", tt.envValue)
} else {
t.Setenv("GATEWAY_INGRESS_MODE", "")
}
if got := gatewayIngressMode(tt.opts); got != tt.want {
t.Errorf("gatewayIngressMode() = %q, want %q", got, tt.want)
}
})
}
}

func TestDeriveGatewayHostname(t *testing.T) {
t.Run("explicit host wins", func(t *testing.T) {
t.Setenv("GATEWAY_API_BASE_DOMAIN", "example.com")
ns := NamespaceConfig{Name: "tenant-a", Gateway: GatewayConfig{Route: RouteConfig{Host: "custom.example.net"}}}
got, err := deriveGatewayHostname(ns)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "custom.example.net" {
t.Errorf("got %q, want custom.example.net", got)
}
})

t.Run("derived from base domain", func(t *testing.T) {
t.Setenv("GATEWAY_API_BASE_DOMAIN", "apps.example.com")
ns := NamespaceConfig{Name: "tenant-a"}
got, err := deriveGatewayHostname(ns)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "gw-tenant-a.apps.example.com" {
t.Errorf("got %q, want gw-tenant-a.apps.example.com", got)
}
})

t.Run("errors when neither host nor base domain set", func(t *testing.T) {
t.Setenv("GATEWAY_API_BASE_DOMAIN", "")
ns := NamespaceConfig{Name: "tenant-a"}
if _, err := deriveGatewayHostname(ns); err == nil {
t.Error("expected error, got nil")
}
})
}
Loading
Loading