From a594f85a6f223901ba2f3f4e4ff6158dc83b375c Mon Sep 17 00:00:00 2001 From: Guillaume Moutier Date: Wed, 29 Jul 2026 16:37:16 -0400 Subject: [PATCH] feat: namespace-aware plugin deployment and BFF runtime config (v0.1.2) Add support for deploying plugins to namespaces defined in their plugin.yaml metadata, with a confirmation modal for custom namespace installs. The Helm chart now manages its own namespace declaratively when it differs from the release namespace, and all documentation and defaults use the shortened cp-plugins-admin namespace. The BFF exposes its own namespace at runtime via the Kubernetes Downward API (GET /api/config), enabling future cross-namespace resource creation (e.g. NetworkPolicies) without hardcoded values. SSE lifecycle endpoints now include a keepalive heartbeat, and the Helm timeout is aligned to prevent premature connection drops. Includes a migration guide for users upgrading from pre-0.1.2 deployments that used the old namespace. --- Signed-off-by: Guillaume Moutier Co-Authored-By: Claude --- README.md | 10 +- bff/__tests__/lifecycleService.test.ts | 85 +++++++++++- bff/package.json | 2 +- bff/src/app.ts | 1 + bff/src/routes/catalog.ts | 1 + bff/src/routes/lifecycle.ts | 6 + bff/src/server.ts | 2 +- bff/src/services/helmService.ts | 2 +- bff/src/services/lifecycleService.ts | 19 +-- bff/src/types/catalog.ts | 2 + chart/Chart.yaml | 4 +- chart/templates/_helpers.tpl | 7 + chart/templates/bff-clusterrolebinding.yaml | 2 +- chart/templates/bff-deployment.yaml | 5 + chart/templates/bff-service.yaml | 1 + chart/templates/bff-serviceaccount.yaml | 1 + chart/templates/deployment.yaml | 1 + chart/templates/namespace.yaml | 8 ++ chart/templates/service.yaml | 1 + chart/templates/serviceaccount.yaml | 1 + chart/values.yaml | 4 + docs/architecture/BFF_PATTERN.md | 4 +- docs/deployment/OPENSHIFT_DEPLOY.md | 50 ++++--- docs/development/BUILD_AND_PUSH.md | 4 +- package-lock.json | 4 +- package.json | 2 +- plugin.yaml | 7 +- src/app/components/ConfirmInstallModal.tsx | 125 ++++++++++++++++++ src/app/components/PluginDetailModal.tsx | 21 ++- .../__tests__/ConfirmInstallModal.spec.tsx | 104 +++++++++++++++ .../__tests__/PluginDetailModal.spec.tsx | 9 +- .../__tests__/useInstalledPluginNames.spec.ts | 1 + src/app/types/catalog.ts | 1 + 33 files changed, 444 insertions(+), 53 deletions(-) create mode 100644 chart/templates/namespace.yaml create mode 100644 src/app/components/ConfirmInstallModal.tsx create mode 100644 src/app/components/__tests__/ConfirmInstallModal.spec.tsx diff --git a/README.md b/README.md index f6ad438..e195b6e 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ Install directly from the OCI registry — no need to clone this repo: ```bash helm install community-plugins-admin oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart \ - --version 0.1.1 \ - --namespace community-plugins-admin \ + --version 0.1.2 \ + --namespace cp-plugins-admin \ --create-namespace ``` @@ -58,7 +58,7 @@ Or, if you have a local checkout of the repository: ```bash helm install community-plugins-admin chart/ \ - --namespace community-plugins-admin \ + --namespace cp-plugins-admin \ --create-namespace ``` @@ -83,7 +83,7 @@ config.append({ 'tls': False, 'service': { 'name': 'community-plugins-admin', - 'namespace': 'community-plugins-admin', + 'namespace': 'cp-plugins-admin', 'port': 8080 } }, @@ -94,7 +94,7 @@ config.append({ 'tls': False, 'service': { 'name': 'community-plugins-admin-bff', - 'namespace': 'community-plugins-admin', + 'namespace': 'cp-plugins-admin', 'port': 3000 } }] diff --git a/bff/__tests__/lifecycleService.test.ts b/bff/__tests__/lifecycleService.test.ts index bf09210..4573899 100644 --- a/bff/__tests__/lifecycleService.test.ts +++ b/bff/__tests__/lifecycleService.test.ts @@ -43,6 +43,10 @@ const FAKE_METADATA = { install: { helm: { registry: 'oci://quay.io/charts/my-plugin' } }, remote: { type: 'module-federation', spec: { name: 'myPlugin', scope: 'myPlugin', paths: [{ type: 'route', path: '/my-plugin' }] } }, }; +const FAKE_METADATA_WITH_NS = { + ...FAKE_METADATA, + install: { namespace: 'cp-my-plugin', helm: { registry: 'oci://quay.io/charts/my-plugin' } }, +}; beforeEach(() => { jest.resetAllMocks(); @@ -126,7 +130,7 @@ describe('upgradePlugin namespace handling', () => { 'oci://quay.io/charts/my-plugin', 'custom-ns', 'token', - undefined, + { namespace: 'custom-ns' }, ); }); @@ -142,7 +146,7 @@ describe('upgradePlugin namespace handling', () => { 'oci://quay.io/charts/my-plugin', 'installed-ns', 'token', - undefined, + { namespace: 'installed-ns' }, ); }); @@ -157,7 +161,23 @@ describe('upgradePlugin namespace handling', () => { 'oci://quay.io/charts/my-plugin', 'my-plugin', 'token', - undefined, + { namespace: 'my-plugin' }, + ); + }); + + it('uses metadata namespace when discovery returns null', async () => { + mockDiscoverReleaseNamespace.mockResolvedValue(null); + mockGetPluginMetadata.mockResolvedValue(FAKE_METADATA_WITH_NS as never); + + const result = await upgradePlugin('my-plugin', 'token'); + + expect(result.success).toBe(true); + expect(mockHelmUpgrade).toHaveBeenCalledWith( + 'my-plugin', + 'oci://quay.io/charts/my-plugin', + 'cp-my-plugin', + 'token', + { namespace: 'cp-my-plugin' }, ); }); @@ -258,7 +278,7 @@ describe('installPlugin', () => { 'oci://quay.io/charts/my-plugin', 'my-plugin', 'token', - undefined, + { namespace: 'my-plugin' }, ); expect(mockAddPluginToConfig).toHaveBeenCalledWith( 'token', @@ -282,7 +302,7 @@ describe('installPlugin', () => { 'oci://quay.io/charts/my-plugin', 'custom-ns', 'token', - undefined, + { namespace: 'custom-ns' }, ); }); @@ -361,6 +381,44 @@ describe('installPlugin', () => { expect(mockHelmUninstall).not.toHaveBeenCalled(); expect(result.steps.find((s) => s.id === 'cleanup')).toBeUndefined(); }); + + it('uses namespace from plugin metadata when no explicit namespace is provided', async () => { + mockGetPluginMetadata.mockResolvedValue(FAKE_METADATA_WITH_NS as never); + + const result = await installPlugin('my-plugin', 'token'); + + expect(result.success).toBe(true); + expect(mockHelmInstall).toHaveBeenCalledWith( + 'my-plugin', + 'oci://quay.io/charts/my-plugin', + 'cp-my-plugin', + 'token', + { namespace: 'cp-my-plugin' }, + ); + expect(mockAddPluginToConfig).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ + backend: expect.objectContaining({ + service: { name: 'my-plugin', namespace: 'cp-my-plugin', port: 8080 }, + }), + }), + ); + }); + + it('explicit namespace overrides metadata namespace', async () => { + mockGetPluginMetadata.mockResolvedValue(FAKE_METADATA_WITH_NS as never); + + const result = await installPlugin('my-plugin', 'token', 'override-ns'); + + expect(result.success).toBe(true); + expect(mockHelmInstall).toHaveBeenCalledWith( + 'my-plugin', + 'oci://quay.io/charts/my-plugin', + 'override-ns', + 'token', + { namespace: 'override-ns' }, + ); + }); }); describe('enablePlugin', () => { @@ -381,6 +439,23 @@ describe('enablePlugin', () => { expect(result.steps.every((s) => s.status === 'completed')).toBe(true); }); + it('uses metadata namespace when discovery returns null', async () => { + mockDiscoverReleaseNamespace.mockResolvedValue(null); + mockGetPluginMetadata.mockResolvedValue(FAKE_METADATA_WITH_NS as never); + + const result = await enablePlugin('my-plugin', 'token'); + + expect(result.success).toBe(true); + expect(mockAddPluginToConfig).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ + backend: expect.objectContaining({ + service: { name: 'my-plugin', namespace: 'cp-my-plugin', port: 8080 }, + }), + }), + ); + }); + it('returns failure when plugin is not found in registry', async () => { mockGetRegistryPlugins.mockResolvedValue([]); diff --git a/bff/package.json b/bff/package.json index 5fcf7a7..adf9b93 100644 --- a/bff/package.json +++ b/bff/package.json @@ -1,6 +1,6 @@ { "name": "community-plugins-admin-bff", - "version": "0.1.1", + "version": "0.1.2", "description": "BFF service for the Community Plugins Admin RHOAI dashboard plugin", "main": "dist/server.js", "scripts": { diff --git a/bff/src/app.ts b/bff/src/app.ts index 41e895b..fbdd8dd 100644 --- a/bff/src/app.ts +++ b/bff/src/app.ts @@ -17,6 +17,7 @@ app.use(express.json({ limit: '100kb' })); app.get('/api/health', (_req, res) => res.json({ status: 'ok' })); app.get('/api/config', (_req, res) => res.json({ + bffNamespace: process.env.POD_NAMESPACE || 'cp-plugins-admin', dashboardNamespace: process.env.DASHBOARD_NAMESPACE || 'redhat-ods-applications', dashboardDeployment: process.env.DASHBOARD_DEPLOYMENT || 'rhods-dashboard', })); diff --git a/bff/src/routes/catalog.ts b/bff/src/routes/catalog.ts index ccf2bcd..f54e5c6 100644 --- a/bff/src/routes/catalog.ts +++ b/bff/src/routes/catalog.ts @@ -24,6 +24,7 @@ function buildCatalogPlugin( if (metadata.install) { install = { method: metadata.install.method, + namespace: metadata.install.namespace, helm: metadata.install.helm ? { chartPath: metadata.install.helm.chart_path, registry: metadata.install.helm.registry } : undefined, diff --git a/bff/src/routes/lifecycle.ts b/bff/src/routes/lifecycle.ts index d3aee02..30b4441 100644 --- a/bff/src/routes/lifecycle.ts +++ b/bff/src/routes/lifecycle.ts @@ -75,6 +75,10 @@ function sendSSE( 'X-Accel-Buffering': 'no', }); + const heartbeat = setInterval(() => { + res.write(': keepalive\n\n'); + }, 15_000); + const onProgress: LifecycleProgressCallback = (steps) => { const data = JSON.stringify({ steps: steps.map(s => ({ ...s })) }); res.write(`event: progress\ndata: ${data}\n\n`); @@ -82,10 +86,12 @@ function sendSSE( serviceFn(onProgress) .then((result) => { + clearInterval(heartbeat); res.write(`event: complete\ndata: ${JSON.stringify(result)}\n\n`); res.end(); }) .catch(() => { + clearInterval(heartbeat); const fallback: LifecycleResponse = { success: false, message: 'Operation failed', diff --git a/bff/src/server.ts b/bff/src/server.ts index 402b26f..c7b544c 100644 --- a/bff/src/server.ts +++ b/bff/src/server.ts @@ -6,7 +6,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10); app.listen(PORT, () => { try { const baseUrl = getK8sBaseUrl(); - console.log(`BFF listening on port ${PORT}`); + console.log(`BFF listening on port ${PORT} (namespace: ${process.env.POD_NAMESPACE ?? 'unknown'})`); console.log(`K8s API target: ${baseUrl}`); } catch { console.error(`BFF listening on port ${PORT}`); diff --git a/bff/src/services/helmService.ts b/bff/src/services/helmService.ts index bbb166c..f546757 100644 --- a/bff/src/services/helmService.ts +++ b/bff/src/services/helmService.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { getK8sBaseUrl } from '../utils/k8sClient'; -const HELM_TIMEOUT_MS = 120_000; +const HELM_TIMEOUT_MS = 330_000; const HELM_BIN = process.env.HELM_BIN || 'helm'; export interface HelmRelease { diff --git a/bff/src/services/lifecycleService.ts b/bff/src/services/lifecycleService.ts index b5709b3..2f644fb 100644 --- a/bff/src/services/lifecycleService.ts +++ b/bff/src/services/lifecycleService.ts @@ -37,6 +37,7 @@ function markFailed(step: LifecycleStep, error: string): void { async function resolvePluginChart(pluginName: string): Promise<{ chart: string; repo: string; + namespace?: string; mfName: string; hasBff: boolean; routePath: string; @@ -61,12 +62,13 @@ async function resolvePluginChart(pluginName: string): Promise<{ throw new Error(`Plugin "${pluginName}" has no Helm chart configured`); } + const ns = metadata.install.namespace; const mfName = metadata.remote?.spec?.name ?? metadata.remote?.spec?.scope ?? kebabToCamelScope(pluginName); const hasBff = !!metadata.bff_image; const routeSpec = metadata.remote?.spec?.paths?.find((p) => p.type === 'route'); const routePath = routeSpec?.path ?? `/${pluginName}`; - return { chart, repo: regEntry.repo, mfName, hasBff, routePath }; + return { chart, repo: regEntry.repo, namespace: ns, mfName, hasBff, routePath }; } export async function installPlugin( @@ -83,19 +85,20 @@ export async function installPlugin( ]; onProgress?.(steps); + let pluginInfo: Awaited> | undefined; try { markRunning(steps[0]); onProgress?.(steps); - const pluginInfo = await resolvePluginChart(pluginName); + pluginInfo = await resolvePluginChart(pluginName); markCompleted(steps[0]); onProgress?.(steps); - const ns = namespace ?? pluginName; + const ns = namespace ?? pluginInfo.namespace ?? pluginName; const releaseName = pluginName; markRunning(steps[1]); onProgress?.(steps); - await helmInstall(releaseName, pluginInfo.chart, ns, token, values); + await helmInstall(releaseName, pluginInfo.chart, ns, token, { namespace: ns, ...values }); markCompleted(steps[1]); onProgress?.(steps); @@ -132,7 +135,7 @@ export async function installPlugin( const helmStep = steps.find((s) => s.id === 'helm-install'); if (helmStep && (helmStep.status === 'completed' || helmStep.status === 'failed')) { - const ns = namespace ?? pluginName; + const ns = namespace ?? pluginInfo?.namespace ?? pluginName; const cleanupStep = createStep('cleanup', 'Rolling back Helm release'); steps.push(cleanupStep); markRunning(cleanupStep); @@ -174,11 +177,11 @@ export async function upgradePlugin( markCompleted(steps[0]); onProgress?.(steps); - const ns = namespace ?? (await discoverReleaseNamespace(pluginName, token)) ?? pluginName; + const ns = namespace ?? (await discoverReleaseNamespace(pluginName, token)) ?? pluginInfo.namespace ?? pluginName; markRunning(steps[1]); onProgress?.(steps); - await helmUpgrade(pluginName, pluginInfo.chart, ns, token, values); + await helmUpgrade(pluginName, pluginInfo.chart, ns, token, { namespace: ns, ...values }); markCompleted(steps[1]); onProgress?.(steps); @@ -284,7 +287,7 @@ export async function enablePlugin( markRunning(steps[1]); onProgress?.(steps); - const ns = (await discoverReleaseNamespace(pluginName, token)) ?? pluginName; + const ns = (await discoverReleaseNamespace(pluginName, token)) ?? pluginInfo.namespace ?? pluginName; const mfEntry: ModuleFederationEntry = { name: pluginInfo.mfName, backend: { diff --git a/bff/src/types/catalog.ts b/bff/src/types/catalog.ts index 3e36203..0b22c1e 100644 --- a/bff/src/types/catalog.ts +++ b/bff/src/types/catalog.ts @@ -23,6 +23,7 @@ export interface PluginImage { export interface PluginInstall { method: 'automatic' | 'assisted' | 'manual'; + namespace?: string; helm?: { chart_path?: string; registry?: string; @@ -99,6 +100,7 @@ export interface CatalogPluginRemote { export interface CatalogPluginInstall { method: 'automatic' | 'assisted' | 'manual'; + namespace?: string; helm?: { chartPath?: string; registry?: string; diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 1a8e1a5..6ced852 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: community-plugins-admin-chart description: A Helm chart for deploying the RHOAI Community Plugins Admin plugin type: application -version: 0.1.1 -appVersion: "0.1.1" +version: 0.1.2 +appVersion: "0.1.2" keywords: - rhoai - openshift-ai diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index fc8bb27..2ace299 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -1,3 +1,10 @@ +{{/* +Target namespace for all namespaced resources. +*/}} +{{- define "community-plugins-admin.namespace" -}} +{{- .Values.namespace | default .Release.Namespace }} +{{- end }} + {{/* Expand the name of the chart. */}} diff --git a/chart/templates/bff-clusterrolebinding.yaml b/chart/templates/bff-clusterrolebinding.yaml index 6d3fcea..80671e9 100644 --- a/chart/templates/bff-clusterrolebinding.yaml +++ b/chart/templates/bff-clusterrolebinding.yaml @@ -17,5 +17,5 @@ roleRef: subjects: - kind: ServiceAccount name: {{ $saName }} - namespace: {{ .Release.Namespace }} + namespace: {{ include "community-plugins-admin.namespace" . }} {{- end }} diff --git a/chart/templates/bff-deployment.yaml b/chart/templates/bff-deployment.yaml index 0ee709a..48d4aea 100644 --- a/chart/templates/bff-deployment.yaml +++ b/chart/templates/bff-deployment.yaml @@ -3,6 +3,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "community-plugins-admin.fullname" . }}-bff + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} app.kubernetes.io/component: bff @@ -48,6 +49,10 @@ spec: containerPort: {{ .Values.bff.service.targetPort }} protocol: TCP env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace - name: DASHBOARD_NAMESPACE value: {{ .Values.bff.dashboardNamespace | quote }} - name: DASHBOARD_DEPLOYMENT diff --git a/chart/templates/bff-service.yaml b/chart/templates/bff-service.yaml index c718e83..8ef4be3 100644 --- a/chart/templates/bff-service.yaml +++ b/chart/templates/bff-service.yaml @@ -3,6 +3,7 @@ apiVersion: v1 kind: Service metadata: name: {{ include "community-plugins-admin.fullname" . }}-bff + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} app.kubernetes.io/component: bff diff --git a/chart/templates/bff-serviceaccount.yaml b/chart/templates/bff-serviceaccount.yaml index 9d0f5e1..7983fac 100644 --- a/chart/templates/bff-serviceaccount.yaml +++ b/chart/templates/bff-serviceaccount.yaml @@ -3,6 +3,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "community-plugins-admin.bffServiceAccountName" . }} + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} app.kubernetes.io/component: bff diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 0e9c5ab..0569fec 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "community-plugins-admin.fullname" . }} + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} spec: diff --git a/chart/templates/namespace.yaml b/chart/templates/namespace.yaml new file mode 100644 index 0000000..cbad44b --- /dev/null +++ b/chart/templates/namespace.yaml @@ -0,0 +1,8 @@ +{{- if ne (include "community-plugins-admin.namespace" .) .Release.Namespace }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "community-plugins-admin.namespace" . }} + labels: + {{- include "community-plugins-admin.labels" . | nindent 4 }} +{{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml index 4adccfe..34f811b 100644 --- a/chart/templates/service.yaml +++ b/chart/templates/service.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: Service metadata: name: {{ include "community-plugins-admin.fullname" . }} + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} spec: diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml index 469d15f..9c21560 100644 --- a/chart/templates/serviceaccount.yaml +++ b/chart/templates/serviceaccount.yaml @@ -3,6 +3,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "community-plugins-admin.serviceAccountName" . }} + namespace: {{ include "community-plugins-admin.namespace" . }} labels: {{- include "community-plugins-admin.labels" . | nindent 4 }} {{- with .Values.serviceAccount.annotations }} diff --git a/chart/values.yaml b/chart/values.yaml index 988313d..80e9e93 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1,6 +1,10 @@ # Default values for community-plugins-admin. # This is a YAML-formatted file. +# -- Target namespace for all namespaced resources. +# Override with --set namespace= to deploy elsewhere. +namespace: cp-plugins-admin + image: repository: quay.io/rh-ai-community-plugins/community-plugins-admin tag: "" diff --git a/docs/architecture/BFF_PATTERN.md b/docs/architecture/BFF_PATTERN.md index e8720af..19cf314 100644 --- a/docs/architecture/BFF_PATTERN.md +++ b/docs/architecture/BFF_PATTERN.md @@ -63,14 +63,14 @@ The dashboard discovers BFF services via the `proxyService` field in the federat "name": "communityPluginsAdmin", "backend": { "remoteEntry": "/remoteEntry.js", - "service": { "name": "community-plugins-admin", "namespace": "community-plugins-admin", "port": 8080 } + "service": { "name": "community-plugins-admin", "namespace": "cp-plugins-admin", "port": 8080 } }, "proxyService": [{ "path": "/community-plugins-admin/api", "pathRewrite": "/api", "authorize": true, "tls": false, - "service": { "name": "community-plugins-admin-bff", "namespace": "community-plugins-admin", "port": 3000 } + "service": { "name": "community-plugins-admin-bff", "namespace": "cp-plugins-admin", "port": 3000 } }] } ``` diff --git a/docs/deployment/OPENSHIFT_DEPLOY.md b/docs/deployment/OPENSHIFT_DEPLOY.md index bd38baa..a08a7c3 100644 --- a/docs/deployment/OPENSHIFT_DEPLOY.md +++ b/docs/deployment/OPENSHIFT_DEPLOY.md @@ -13,14 +13,34 @@ This guide walks through deploying the plugin on an OpenShift cluster that alrea --- +## Migrating from Pre-0.1.2 Installations + +Version 0.1.2 changed the default namespace from `community-plugins-admin` to `cp-plugins-admin`. If you previously installed the plugin under the old namespace, cluster-scoped resources (ClusterRole, ClusterRoleBinding) may still exist with Helm ownership annotations pointing to the old namespace. Clean them up before reinstalling: + +```bash +# 1. Uninstall the old release (if still present) +helm uninstall community-plugins-admin -n community-plugins-admin + +# 2. Remove leftover cluster-scoped resources +oc delete clusterrole community-plugins-admin-bff +oc delete clusterrolebinding community-plugins-admin-bff + +# 3. Remove the old namespace (if no longer needed) +oc delete namespace community-plugins-admin +``` + +Then proceed with a fresh install using the commands below. + +--- + ## 1. Install the Plugin Install directly from the OCI registry — no need to clone the repo: ```bash helm install community-plugins-admin oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart \ - --version 0.1.1 \ - --namespace community-plugins-admin \ + --version 0.1.2 \ + --namespace cp-plugins-admin \ --create-namespace ``` @@ -28,7 +48,7 @@ Or, from a local checkout of the repository: ```bash helm install community-plugins-admin chart/ \ - --namespace community-plugins-admin \ + --namespace cp-plugins-admin \ --create-namespace ``` @@ -44,8 +64,8 @@ Pass `--set` flags to customize the installation: ```bash helm install community-plugins-admin oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart \ - --version 0.1.1 \ - --namespace community-plugins-admin \ + --version 0.1.2 \ + --namespace cp-plugins-admin \ --create-namespace \ --set replicaCount=2 ``` @@ -54,8 +74,8 @@ To deploy the frontend only (no BFF): ```bash helm install community-plugins-admin oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart \ - --version 0.1.1 \ - --namespace community-plugins-admin \ + --version 0.1.2 \ + --namespace cp-plugins-admin \ --create-namespace \ --set bff.enabled=false ``` @@ -87,7 +107,7 @@ config.append({ 'tls': False, 'service': { 'name': 'community-plugins-admin', - 'namespace': 'community-plugins-admin', + 'namespace': 'cp-plugins-admin', 'port': 8080 } } @@ -119,7 +139,7 @@ config.append({ 'tls': False, 'service': { 'name': 'community-plugins-admin', - 'namespace': 'community-plugins-admin', + 'namespace': 'cp-plugins-admin', 'port': 8080 } }, @@ -130,7 +150,7 @@ config.append({ 'tls': False, 'service': { 'name': 'community-plugins-admin-bff', - 'namespace': 'community-plugins-admin', + 'namespace': 'cp-plugins-admin', 'port': 3000 } }] @@ -177,7 +197,7 @@ for entry in data: Verify the plugin pods are running: ```bash -oc get pods -n community-plugins-admin +oc get pods -n cp-plugins-admin ``` You should see pods for `community-plugins-admin` (and `community-plugins-admin-bff` if BFF is enabled), all in `Running` status. @@ -217,7 +237,7 @@ If you manage RBAC separately or use a pre-existing ServiceAccount, disable the ```bash helm install community-plugins-admin chart/ \ - --namespace community-plugins-admin \ + --namespace cp-plugins-admin \ --create-namespace \ --set bff.rbac.create=false ``` @@ -228,7 +248,7 @@ To use a ServiceAccount that already exists in the cluster: ```bash helm install community-plugins-admin chart/ \ - --namespace community-plugins-admin \ + --namespace cp-plugins-admin \ --create-namespace \ --set bff.serviceAccount.create=false \ --set bff.serviceAccount.name=my-existing-sa \ @@ -262,8 +282,8 @@ oc set env deployment/rhods-dashboard \ ### 2. Uninstall the Helm release ```bash -helm uninstall community-plugins-admin -n community-plugins-admin -oc delete namespace community-plugins-admin # optional: remove the namespace entirely +helm uninstall community-plugins-admin -n cp-plugins-admin +oc delete namespace cp-plugins-admin # optional: remove the namespace entirely ``` --- diff --git a/docs/development/BUILD_AND_PUSH.md b/docs/development/BUILD_AND_PUSH.md index 163dfcd..f60fce2 100644 --- a/docs/development/BUILD_AND_PUSH.md +++ b/docs/development/BUILD_AND_PUSH.md @@ -182,8 +182,8 @@ make chart-push ```bash helm install community-plugins-admin oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart \ - --version 0.1.1 \ - --namespace community-plugins-admin \ + --version 0.1.2 \ + --namespace cp-plugins-admin \ --create-namespace ``` diff --git a/package-lock.json b/package-lock.json index aae5269..a5e0721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "community-plugins-admin", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "community-plugins-admin", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@openshift/dynamic-plugin-sdk": "^5", "@patternfly/react-core": "^6", diff --git a/package.json b/package.json index 256c310..b00e923 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "community-plugins-admin", - "version": "0.1.1", + "version": "0.1.2", "description": "Discover, install, upgrade, and manage community plugins for the RHOAI Dashboard", "private": true, "_comment_name": "[PLUGIN-SPECIFIC] package name — change to {your-plugin}", diff --git a/plugin.yaml b/plugin.yaml index 175f345..e4f4dac 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -5,7 +5,7 @@ description: >- Discover, install, upgrade, remove, enable, and disable community plugins for the RHOAI Dashboard. Browse the community plugin catalog, view detailed plugin metadata, and manage the full plugin lifecycle from the dashboard UI. -version: 0.1.1 +version: 0.1.2 # --- Maintainer (required) --- maintainer: # [PLUGIN-SPECIFIC] @@ -22,15 +22,16 @@ deployment_model: cluster-shared # per-project | cluster-shared | both image: # [PLUGIN-SPECIFIC] repository: quay.io/rh-ai-community-plugins/community-plugins-admin - tag: "0.1.1" + tag: "0.1.2" bff_image: # [PLUGIN-SPECIFIC] (optional — only if using BFF pattern) repository: quay.io/rh-ai-community-plugins/community-plugins-admin-bff - tag: "0.1.1" + tag: "0.1.2" # --- Installation (required) --- install: method: automatic # automatic | assisted | manual + namespace: cp-plugins-admin helm: chart_path: chart/ registry: oci://quay.io/rh-ai-community-plugins/community-plugins-admin-chart # [PLUGIN-SPECIFIC] diff --git a/src/app/components/ConfirmInstallModal.tsx b/src/app/components/ConfirmInstallModal.tsx new file mode 100644 index 0000000..c9a4593 --- /dev/null +++ b/src/app/components/ConfirmInstallModal.tsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Button, + TextInput, + FormGroup, + Form, + HelperText, + HelperTextItem, + Checkbox, +} from '@patternfly/react-core'; + +const K8S_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/; + +interface ConfirmInstallModalProps { + pluginName: string | null; + defaultNamespace: string; + isOpen: boolean; + isLoading: boolean; + onConfirm: (namespace: string) => void; + onCancel: () => void; +} + +const ConfirmInstallModal: React.FC = ({ + pluginName, + defaultNamespace, + isOpen, + isLoading, + onConfirm, + onCancel, +}) => { + const [overrideEnabled, setOverrideEnabled] = useState(false); + const [namespaceInput, setNamespaceInput] = useState(defaultNamespace); + + useEffect(() => { + setOverrideEnabled(false); + setNamespaceInput(defaultNamespace); + }, [pluginName, defaultNamespace]); + + const effectiveNamespace = overrideEnabled ? namespaceInput : defaultNamespace; + const isValid = K8S_NAMESPACE_PATTERN.test(effectiveNamespace); + const showError = overrideEnabled && namespaceInput.length > 0 && !K8S_NAMESPACE_PATTERN.test(namespaceInput); + + const handleConfirm = () => { + if (!isValid) return; + onConfirm(effectiveNamespace); + }; + + const handleClose = () => { + setOverrideEnabled(false); + setNamespaceInput(defaultNamespace); + onCancel(); + }; + + return ( + + + +

+ This plugin will be installed in namespace {defaultNamespace}. +

+
+ setOverrideEnabled(checked)} + isDisabled={isLoading} + /> + {overrideEnabled && ( + + setNamespaceInput(value)} + aria-label="Namespace" + isDisabled={isLoading} + /> + {showError ? ( + + + Must start with a letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit. + + + ) : ( + + + Use a "cp-" prefix for consistency (e.g. cp-{pluginName}). + + + )} + + )} + +
+ + + + +
+ ); +}; + +export default ConfirmInstallModal; diff --git a/src/app/components/PluginDetailModal.tsx b/src/app/components/PluginDetailModal.tsx index 63c01bf..08fcc3e 100644 --- a/src/app/components/PluginDetailModal.tsx +++ b/src/app/components/PluginDetailModal.tsx @@ -33,6 +33,7 @@ import { statusLabelColor, deploymentModelLabel, } from '~/app/utils/maintenance'; +import ConfirmInstallModal from '~/app/components/ConfirmInstallModal'; import ConfirmRemoveModal from '~/app/components/ConfirmRemoveModal'; import LifecycleProgressModal from '~/app/components/LifecycleProgressModal'; @@ -462,19 +463,27 @@ const PluginDetailModal: React.FC = ({ const disabled = !installed && !!pluginName && !!(helmInstalledNames?.has(pluginName)); + const [showInstallConfirm, setShowInstallConfirm] = useState(false); const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); const [showProgress, setShowProgress] = useState(false); + const defaultNamespace = plugin?.install?.namespace ?? pluginName ?? ''; + const handleLifecycleComplete = () => { setShowProgress(false); lifecycle.reset(); onLifecycleComplete?.(); }; - const handleInstall = async () => { + const handleInstall = () => { + setShowInstallConfirm(true); + }; + + const handleInstallConfirm = async (namespace: string) => { if (!pluginName) return; + setShowInstallConfirm(false); setShowProgress(true); - await lifecycle.install(pluginName); + await lifecycle.install(pluginName, namespace); }; const handleUpgrade = async () => { @@ -552,6 +561,14 @@ const PluginDetailModal: React.FC = ({ )} + setShowInstallConfirm(false)} + /> { + const defaultProps = { + pluginName: 'my-plugin', + defaultNamespace: 'cp-my-plugin', + isOpen: true, + isLoading: false, + onConfirm: jest.fn(), + onCancel: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal when isOpen is true', () => { + render(); + expect(screen.getByText('Install plugin')).toBeInTheDocument(); + expect(screen.getByText('cp-my-plugin')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByText('Install plugin')).not.toBeInTheDocument(); + }); + + it('Install button is enabled by default', () => { + render(); + const installBtn = screen.getByRole('button', { name: 'Install' }); + expect(installBtn).not.toBeDisabled(); + }); + + it('calls onConfirm with default namespace when installed without override', async () => { + const onConfirm = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Install' })); + expect(onConfirm).toHaveBeenCalledWith('cp-my-plugin'); + }); + + it('checking the checkbox reveals the namespace input', async () => { + render(); + expect(screen.queryByLabelText('Namespace')).not.toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); + }); + + it('namespace input is pre-filled with the default namespace', async () => { + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + expect(screen.getByLabelText('Namespace')).toHaveValue('cp-my-plugin'); + }); + + it('calls onConfirm with overridden namespace', async () => { + const onConfirm = jest.fn(); + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + const input = screen.getByLabelText('Namespace'); + await userEvent.clear(input); + await userEvent.type(input, 'custom-ns'); + await userEvent.click(screen.getByRole('button', { name: 'Install' })); + expect(onConfirm).toHaveBeenCalledWith('custom-ns'); + }); + + it('Install button is disabled when override is enabled and input is invalid', async () => { + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + const input = screen.getByLabelText('Namespace'); + await userEvent.clear(input); + await userEvent.type(input, 'INVALID'); + expect(screen.getByRole('button', { name: 'Install' })).toBeDisabled(); + }); + + it('shows validation error for invalid namespace', async () => { + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + const input = screen.getByLabelText('Namespace'); + await userEvent.clear(input); + await userEvent.type(input, 'INVALID'); + expect(screen.getByText(/Must start with a letter/)).toBeInTheDocument(); + }); + + it('shows cp- prefix hint when override is active and input is valid', async () => { + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + expect(screen.getByText(/prefix for consistency/)).toBeInTheDocument(); + }); + + it('calls onCancel when Cancel is clicked', async () => { + const onCancel = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('disables input and buttons when isLoading is true', async () => { + render(); + await userEvent.click(screen.getByLabelText('Install in a different namespace')); + // Checkbox is disabled so override won't show - check buttons only + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); +}); diff --git a/src/app/components/__tests__/PluginDetailModal.spec.tsx b/src/app/components/__tests__/PluginDetailModal.spec.tsx index a4f8bc6..f7cc865 100644 --- a/src/app/components/__tests__/PluginDetailModal.spec.tsx +++ b/src/app/components/__tests__/PluginDetailModal.spec.tsx @@ -428,13 +428,18 @@ describe('PluginDetailModal', () => { expect(installButton).toHaveAttribute('aria-disabled', 'true'); }); - it('should call lifecycle.install when Install button is clicked', async () => { + it('should open install confirmation and call lifecycle.install on confirm', async () => { mockUsePluginDetail.mockReturnValue(loadedResult(fullPlugin, false)); render( , ); await userEvent.click(screen.getByRole('button', { name: 'Install' })); - expect(mockLifecycle.install).toHaveBeenCalledWith('test-plugin'); + expect(screen.getByText('Install plugin')).toBeInTheDocument(); + const confirmButtons = screen.getAllByRole('button', { name: 'Install', hidden: true }); + const confirmBtn = confirmButtons.find((btn) => btn.closest('[aria-label="Confirm plugin installation"]')); + expect(confirmBtn).toBeDefined(); + await userEvent.click(confirmBtn!); + expect(mockLifecycle.install).toHaveBeenCalledWith('test-plugin', 'test-plugin'); }); it('should call lifecycle.upgrade when Upgrade button is clicked', async () => { diff --git a/src/app/hooks/__tests__/useInstalledPluginNames.spec.ts b/src/app/hooks/__tests__/useInstalledPluginNames.spec.ts index ea1f9e6..305efb3 100644 --- a/src/app/hooks/__tests__/useInstalledPluginNames.spec.ts +++ b/src/app/hooks/__tests__/useInstalledPluginNames.spec.ts @@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useInstalledPluginNames, scopeToKebab } from '../useInstalledPluginNames'; const mockConfig = { + bffNamespace: 'cp-plugins-admin', dashboardNamespace: 'redhat-ods-applications', dashboardDeployment: 'rhods-dashboard', }; diff --git a/src/app/types/catalog.ts b/src/app/types/catalog.ts index a101adc..72a80a5 100644 --- a/src/app/types/catalog.ts +++ b/src/app/types/catalog.ts @@ -10,6 +10,7 @@ export interface PluginImage { export interface CatalogPluginInstall { method: 'automatic' | 'assisted' | 'manual'; + namespace?: string; helm?: { chartPath?: string; registry?: string;