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;